diff --git a/src/actions/actionElementLock.test.tsx b/src/actions/actionElementLock.test.tsx new file mode 100644 index 00000000..19db5e32 --- /dev/null +++ b/src/actions/actionElementLock.test.tsx @@ -0,0 +1,68 @@ +import { Excalidraw } from "../packages/excalidraw/index"; +import { queryByTestId, fireEvent } from "@testing-library/react"; +import { render } from "../tests/test-utils"; +import { Pointer, UI } from "../tests/helpers/ui"; +import { API } from "../tests/helpers/api"; + +const { h } = window; +const mouse = new Pointer("mouse"); + +describe("element locking", () => { + it("should not show unlockAllElements action in contextMenu if no elements locked", async () => { + await render(); + + mouse.rightClickAt(0, 0); + + const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements"); + expect(item).toBe(null); + }); + + it("should unlock all elements and select them when using unlockAllElements action in contextMenu", async () => { + await render( + , + ); + + mouse.rightClickAt(0, 0); + + expect(Object.keys(h.state.selectedElementIds).length).toBe(0); + expect(h.elements.map((el) => el.locked)).toEqual([true, true, false]); + + const item = queryByTestId(UI.queryContextMenu()!, "unlockAllElements"); + expect(item).not.toBe(null); + + fireEvent.click(item!.querySelector("button")!); + + expect(h.elements.map((el) => el.locked)).toEqual([false, false, false]); + // should select the unlocked elements + expect(h.state.selectedElementIds).toEqual({ + [h.elements[0].id]: true, + [h.elements[1].id]: true, + }); + }); +}); diff --git a/src/actions/actionToggleLock.ts b/src/actions/actionElementLock.ts similarity index 53% rename from src/actions/actionToggleLock.ts rename to src/actions/actionElementLock.ts index c44bd570..922a5fae 100644 --- a/src/actions/actionToggleLock.ts +++ b/src/actions/actionElementLock.ts @@ -5,8 +5,11 @@ import { getSelectedElements } from "../scene"; import { arrayToMap } from "../utils"; import { register } from "./register"; -export const actionToggleLock = register({ - name: "toggleLock", +const shouldLock = (elements: readonly ExcalidrawElement[]) => + elements.every((el) => !el.locked); + +export const actionToggleElementLock = register({ + name: "toggleElementLock", trackEvent: { category: "element" }, perform: (elements, appState) => { const selectedElements = getSelectedElements(elements, appState, true); @@ -15,20 +18,21 @@ export const actionToggleLock = register({ return false; } - const operation = getOperation(selectedElements); + const nextLockState = shouldLock(selectedElements); const selectedElementsMap = arrayToMap(selectedElements); - const lock = operation === "lock"; return { elements: elements.map((element) => { if (!selectedElementsMap.has(element.id)) { return element; } - return newElementWith(element, { locked: lock }); + return newElementWith(element, { locked: nextLockState }); }), appState: { ...appState, - selectedLinearElement: lock ? null : appState.selectedLinearElement, + selectedLinearElement: nextLockState + ? null + : appState.selectedLinearElement, }, commitToHistory: true, }; @@ -41,7 +45,7 @@ export const actionToggleLock = register({ : "labels.elementLock.lock"; } - return getOperation(selected) === "lock" + return shouldLock(selected) ? "labels.elementLock.lockAll" : "labels.elementLock.unlockAll"; }, @@ -55,6 +59,31 @@ export const actionToggleLock = register({ }, }); -const getOperation = ( - elements: readonly ExcalidrawElement[], -): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock"); +export const actionUnlockAllElements = register({ + name: "unlockAllElements", + trackEvent: { category: "canvas" }, + viewMode: false, + predicate: (elements) => { + return elements.some((element) => element.locked); + }, + perform: (elements, appState) => { + const lockedElements = elements.filter((el) => el.locked); + + return { + elements: elements.map((element) => { + if (element.locked) { + return newElementWith(element, { locked: false }); + } + return element; + }), + appState: { + ...appState, + selectedElementIds: Object.fromEntries( + lockedElements.map((el) => [el.id, true]), + ), + }, + commitToHistory: true, + }; + }, + contextItemLabel: "labels.elementLock.unlockAll", +}); diff --git a/src/actions/index.ts b/src/actions/index.ts index eea4faf7..9b53f817 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -84,5 +84,5 @@ export { actionToggleZenMode } from "./actionToggleZenMode"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionLink } from "../element/Hyperlink"; -export { actionToggleLock } from "./actionToggleLock"; +export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index be48c647..b9c24a75 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -34,7 +34,7 @@ export type ShortcutName = | "flipHorizontal" | "flipVertical" | "hyperlink" - | "toggleLock" + | "toggleElementLock" > | "saveScene" | "imageExport"; @@ -80,7 +80,7 @@ const shortcutMap: Record = { flipVertical: [getShortcutKey("Shift+V")], viewMode: [getShortcutKey("Alt+R")], hyperlink: [getShortcutKey("CtrlOrCmd+K")], - toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")], + toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")], }; export const getShortcutFromShortcutName = (name: ShortcutName) => { diff --git a/src/actions/types.ts b/src/actions/types.ts index b03e1053..277934ad 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -111,7 +111,8 @@ export type ActionName = | "unbindText" | "hyperlink" | "bindText" - | "toggleLock" + | "unlockAllElements" + | "toggleElementLock" | "toggleLinearEditor" | "toggleEraserTool" | "toggleHandTool" diff --git a/src/components/App.tsx b/src/components/App.tsx index a317dd9b..1bf2d8e6 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -33,7 +33,7 @@ import { actionBindText, actionUngroup, actionLink, - actionToggleLock, + actionToggleElementLock, actionToggleLinearEditor, } from "../actions"; import { createRedoAction, createUndoAction } from "../actions/actionHistory"; @@ -290,6 +290,7 @@ import { isLocalLink, } from "../element/Hyperlink"; import { shouldShowBoundingBox } from "../element/transformHandles"; +import { actionUnlockAllElements } from "../actions/actionElementLock"; import { Fonts } from "../scene/Fonts"; import { actionPaste } from "../actions/actionClipboard"; import { @@ -6347,6 +6348,7 @@ class App extends React.Component { copyText, CONTEXT_MENU_SEPARATOR, actionSelectAll, + actionUnlockAllElements, CONTEXT_MENU_SEPARATOR, actionToggleGridMode, actionToggleZenMode, @@ -6393,7 +6395,7 @@ class App extends React.Component { actionToggleLinearEditor, actionLink, actionDuplicateSelection, - actionToggleLock, + actionToggleElementLock, CONTEXT_MENU_SEPARATOR, actionDeleteSelected, ]; diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index 43e4c0f7..5b69dbe0 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/src/tests/__snapshots__/contextmenu.test.tsx.snap @@ -247,7 +247,7 @@ Object { Object { "contextItemLabel": [Function], "keyTest": [Function], - "name": "toggleLock", + "name": "toggleElementLock", "perform": [Function], "trackEvent": Object { "category": "element", @@ -4644,7 +4644,7 @@ Object { Object { "contextItemLabel": [Function], "keyTest": [Function], - "name": "toggleLock", + "name": "toggleElementLock", "perform": [Function], "trackEvent": Object { "category": "element", @@ -5194,7 +5194,7 @@ Object { Object { "contextItemLabel": [Function], "keyTest": [Function], - "name": "toggleLock", + "name": "toggleElementLock", "perform": [Function], "trackEvent": Object { "category": "element", @@ -5642,6 +5642,16 @@ Object { "category": "canvas", }, }, + Object { + "contextItemLabel": "labels.elementLock.unlockAll", + "name": "unlockAllElements", + "perform": [Function], + "predicate": [Function], + "trackEvent": Object { + "category": "canvas", + }, + "viewMode": false, + }, "separator", Object { "checked": [Function], @@ -6043,7 +6053,7 @@ Object { Object { "contextItemLabel": [Function], "keyTest": [Function], - "name": "toggleLock", + "name": "toggleElementLock", "perform": [Function], "trackEvent": Object { "category": "element", @@ -6389,7 +6399,7 @@ Object { Object { "contextItemLabel": [Function], "keyTest": [Function], - "name": "toggleLock", + "name": "toggleElementLock", "perform": [Function], "trackEvent": Object { "category": "element", diff --git a/src/tests/contextmenu.test.tsx b/src/tests/contextmenu.test.tsx index 55841b24..0a74de36 100644 --- a/src/tests/contextmenu.test.tsx +++ b/src/tests/contextmenu.test.tsx @@ -125,7 +125,7 @@ describe("contextMenu element", () => { "bringToFront", "duplicateSelection", "hyperlink", - "toggleLock", + "toggleElementLock", ]; expect(contextMenu).not.toBeNull(); @@ -212,7 +212,7 @@ describe("contextMenu element", () => { "sendToBack", "bringToFront", "duplicateSelection", - "toggleLock", + "toggleElementLock", ]; expect(contextMenu).not.toBeNull(); @@ -263,7 +263,7 @@ describe("contextMenu element", () => { "sendToBack", "bringToFront", "duplicateSelection", - "toggleLock", + "toggleElementLock", ]; expect(contextMenu).not.toBeNull(); @@ -287,7 +287,7 @@ describe("contextMenu element", () => { }); const contextMenu = UI.queryContextMenu(); expect(copiedStyles).toBe("{}"); - fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); + fireEvent.click(queryByText(contextMenu!, "Copy styles")!); expect(copiedStyles).not.toBe("{}"); const element = JSON.parse(copiedStyles)[0]; expect(element).toEqual(API.getSelectedElement()); @@ -328,7 +328,7 @@ describe("contextMenu element", () => { clientY: 40, }); let contextMenu = UI.queryContextMenu(); - fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); + fireEvent.click(queryByText(contextMenu!, "Copy styles")!); const secondRect = JSON.parse(copiedStyles)[0]; expect(secondRect.id).toBe(h.elements[1].id); @@ -340,7 +340,7 @@ describe("contextMenu element", () => { clientY: 10, }); contextMenu = UI.queryContextMenu(); - fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!); + fireEvent.click(queryByText(contextMenu!, "Paste styles")!); const firstRect = API.getSelectedElement(); expect(firstRect.id).toBe(h.elements[0].id); @@ -364,7 +364,7 @@ describe("contextMenu element", () => { clientY: 1, }); const contextMenu = UI.queryContextMenu(); - fireEvent.click(queryAllByText(contextMenu as HTMLElement, "Delete")[0]); + fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]); expect(API.getSelectedElements()).toHaveLength(0); expect(h.elements[0].isDeleted).toBe(true); }); @@ -380,7 +380,7 @@ describe("contextMenu element", () => { clientY: 1, }); const contextMenu = UI.queryContextMenu(); - fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!); + fireEvent.click(queryByText(contextMenu!, "Add to library")!); await waitFor(() => { const library = localStorage.getItem("excalidraw-library"); @@ -401,7 +401,7 @@ describe("contextMenu element", () => { clientY: 1, }); const contextMenu = UI.queryContextMenu(); - fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!); + fireEvent.click(queryByText(contextMenu!, "Duplicate")!); expect(h.elements).toHaveLength(2); const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0]; const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1]; @@ -425,7 +425,7 @@ describe("contextMenu element", () => { }); const contextMenu = UI.queryContextMenu(); const elementsBefore = h.elements; - fireEvent.click(queryByText(contextMenu as HTMLElement, "Send backward")!); + fireEvent.click(queryByText(contextMenu!, "Send backward")!); expect(elementsBefore[0].id).toEqual(h.elements[1].id); expect(elementsBefore[1].id).toEqual(h.elements[0].id); }); @@ -447,7 +447,7 @@ describe("contextMenu element", () => { }); const contextMenu = UI.queryContextMenu(); const elementsBefore = h.elements; - fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring forward")!); + fireEvent.click(queryByText(contextMenu!, "Bring forward")!); expect(elementsBefore[0].id).toEqual(h.elements[1].id); expect(elementsBefore[1].id).toEqual(h.elements[0].id); }); @@ -469,7 +469,7 @@ describe("contextMenu element", () => { }); const contextMenu = UI.queryContextMenu(); const elementsBefore = h.elements; - fireEvent.click(queryByText(contextMenu as HTMLElement, "Send to back")!); + fireEvent.click(queryByText(contextMenu!, "Send to back")!); expect(elementsBefore[1].id).toEqual(h.elements[0].id); }); @@ -490,7 +490,7 @@ describe("contextMenu element", () => { }); const contextMenu = UI.queryContextMenu(); const elementsBefore = h.elements; - fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring to front")!); + fireEvent.click(queryByText(contextMenu!, "Bring to front")!); expect(elementsBefore[0].id).toEqual(h.elements[1].id); }); @@ -514,9 +514,7 @@ describe("contextMenu element", () => { clientY: 1, }); const contextMenu = UI.queryContextMenu(); - fireEvent.click( - queryByText(contextMenu as HTMLElement, "Group selection")!, - ); + fireEvent.click(queryByText(contextMenu!, "Group selection")!); const selectedGroupIds = Object.keys(h.state.selectedGroupIds); expect(h.elements[0].groupIds).toEqual(selectedGroupIds); expect(h.elements[1].groupIds).toEqual(selectedGroupIds); @@ -548,9 +546,7 @@ describe("contextMenu element", () => { const contextMenu = UI.queryContextMenu(); expect(contextMenu).not.toBeNull(); - fireEvent.click( - queryByText(contextMenu as HTMLElement, "Ungroup selection")!, - ); + fireEvent.click(queryByText(contextMenu!, "Ungroup selection")!); const selectedGroupIds = Object.keys(h.state.selectedGroupIds); expect(selectedGroupIds).toHaveLength(0); diff --git a/src/tests/elementLocking.test.tsx b/src/tests/elementLocking.test.tsx index 9cf0968d..a2f6eb11 100644 --- a/src/tests/elementLocking.test.tsx +++ b/src/tests/elementLocking.test.tsx @@ -152,7 +152,7 @@ describe("element locking", () => { expect(contextMenu).not.toBeNull(); expect( contextMenu?.querySelector( - `li[data-testid="toggleLock"] .context-menu-item__label`, + `li[data-testid="toggleElementLock"] .context-menu-item__label`, ), ).toHaveTextContent(t("labels.elementLock.unlock")); }); diff --git a/src/tests/helpers/ui.ts b/src/tests/helpers/ui.ts index 1cfdb10a..fcf67166 100644 --- a/src/tests/helpers/ui.ts +++ b/src/tests/helpers/ui.ts @@ -321,6 +321,6 @@ export class UI { static queryContextMenu = () => { return GlobalTestState.renderResult.container.querySelector( ".context-menu", - ); + ) as HTMLElement | null; }; }