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;
};
}