feat: add "unlock all elements" to canvas contextMenu (#5894)

This commit is contained in:
David Luzar 2023-05-13 22:52:03 +02:00 committed by GitHub
parent 5bf27a463c
commit b1b325b9a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 148 additions and 42 deletions

View File

@ -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(<Excalidraw />);
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(
<Excalidraw
initialData={{
elements: [
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: true,
}),
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: true,
}),
API.createElement({
x: 100,
y: 100,
width: 100,
height: 100,
locked: false,
}),
],
}}
/>,
);
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,
});
});
});

View File

@ -5,8 +5,11 @@ import { getSelectedElements } from "../scene";
import { arrayToMap } from "../utils"; import { arrayToMap } from "../utils";
import { register } from "./register"; import { register } from "./register";
export const actionToggleLock = register({ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
name: "toggleLock", elements.every((el) => !el.locked);
export const actionToggleElementLock = register({
name: "toggleElementLock",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState) => { perform: (elements, appState) => {
const selectedElements = getSelectedElements(elements, appState, true); const selectedElements = getSelectedElements(elements, appState, true);
@ -15,20 +18,21 @@ export const actionToggleLock = register({
return false; return false;
} }
const operation = getOperation(selectedElements); const nextLockState = shouldLock(selectedElements);
const selectedElementsMap = arrayToMap(selectedElements); const selectedElementsMap = arrayToMap(selectedElements);
const lock = operation === "lock";
return { return {
elements: elements.map((element) => { elements: elements.map((element) => {
if (!selectedElementsMap.has(element.id)) { if (!selectedElementsMap.has(element.id)) {
return element; return element;
} }
return newElementWith(element, { locked: lock }); return newElementWith(element, { locked: nextLockState });
}), }),
appState: { appState: {
...appState, ...appState,
selectedLinearElement: lock ? null : appState.selectedLinearElement, selectedLinearElement: nextLockState
? null
: appState.selectedLinearElement,
}, },
commitToHistory: true, commitToHistory: true,
}; };
@ -41,7 +45,7 @@ export const actionToggleLock = register({
: "labels.elementLock.lock"; : "labels.elementLock.lock";
} }
return getOperation(selected) === "lock" return shouldLock(selected)
? "labels.elementLock.lockAll" ? "labels.elementLock.lockAll"
: "labels.elementLock.unlockAll"; : "labels.elementLock.unlockAll";
}, },
@ -55,6 +59,31 @@ export const actionToggleLock = register({
}, },
}); });
const getOperation = ( export const actionUnlockAllElements = register({
elements: readonly ExcalidrawElement[], name: "unlockAllElements",
): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock"); 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",
});

View File

@ -84,5 +84,5 @@ export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink"; export { actionLink } from "../element/Hyperlink";
export { actionToggleLock } from "./actionToggleLock"; export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleLinearEditor } from "./actionLinearEditor";

View File

@ -34,7 +34,7 @@ export type ShortcutName =
| "flipHorizontal" | "flipHorizontal"
| "flipVertical" | "flipVertical"
| "hyperlink" | "hyperlink"
| "toggleLock" | "toggleElementLock"
> >
| "saveScene" | "saveScene"
| "imageExport"; | "imageExport";
@ -80,7 +80,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
hyperlink: [getShortcutKey("CtrlOrCmd+K")], hyperlink: [getShortcutKey("CtrlOrCmd+K")],
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")], toggleElementLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export const getShortcutFromShortcutName = (name: ShortcutName) => {

View File

@ -111,7 +111,8 @@ export type ActionName =
| "unbindText" | "unbindText"
| "hyperlink" | "hyperlink"
| "bindText" | "bindText"
| "toggleLock" | "unlockAllElements"
| "toggleElementLock"
| "toggleLinearEditor" | "toggleLinearEditor"
| "toggleEraserTool" | "toggleEraserTool"
| "toggleHandTool" | "toggleHandTool"

View File

@ -33,7 +33,7 @@ import {
actionBindText, actionBindText,
actionUngroup, actionUngroup,
actionLink, actionLink,
actionToggleLock, actionToggleElementLock,
actionToggleLinearEditor, actionToggleLinearEditor,
} from "../actions"; } from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { createRedoAction, createUndoAction } from "../actions/actionHistory";
@ -290,6 +290,7 @@ import {
isLocalLink, isLocalLink,
} from "../element/Hyperlink"; } from "../element/Hyperlink";
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock";
import { Fonts } from "../scene/Fonts"; import { Fonts } from "../scene/Fonts";
import { actionPaste } from "../actions/actionClipboard"; import { actionPaste } from "../actions/actionClipboard";
import { import {
@ -6347,6 +6348,7 @@ class App extends React.Component<AppProps, AppState> {
copyText, copyText,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionSelectAll, actionSelectAll,
actionUnlockAllElements,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionToggleGridMode, actionToggleGridMode,
actionToggleZenMode, actionToggleZenMode,
@ -6393,7 +6395,7 @@ class App extends React.Component<AppProps, AppState> {
actionToggleLinearEditor, actionToggleLinearEditor,
actionLink, actionLink,
actionDuplicateSelection, actionDuplicateSelection,
actionToggleLock, actionToggleElementLock,
CONTEXT_MENU_SEPARATOR, CONTEXT_MENU_SEPARATOR,
actionDeleteSelected, actionDeleteSelected,
]; ];

View File

@ -247,7 +247,7 @@ Object {
Object { Object {
"contextItemLabel": [Function], "contextItemLabel": [Function],
"keyTest": [Function], "keyTest": [Function],
"name": "toggleLock", "name": "toggleElementLock",
"perform": [Function], "perform": [Function],
"trackEvent": Object { "trackEvent": Object {
"category": "element", "category": "element",
@ -4644,7 +4644,7 @@ Object {
Object { Object {
"contextItemLabel": [Function], "contextItemLabel": [Function],
"keyTest": [Function], "keyTest": [Function],
"name": "toggleLock", "name": "toggleElementLock",
"perform": [Function], "perform": [Function],
"trackEvent": Object { "trackEvent": Object {
"category": "element", "category": "element",
@ -5194,7 +5194,7 @@ Object {
Object { Object {
"contextItemLabel": [Function], "contextItemLabel": [Function],
"keyTest": [Function], "keyTest": [Function],
"name": "toggleLock", "name": "toggleElementLock",
"perform": [Function], "perform": [Function],
"trackEvent": Object { "trackEvent": Object {
"category": "element", "category": "element",
@ -5642,6 +5642,16 @@ Object {
"category": "canvas", "category": "canvas",
}, },
}, },
Object {
"contextItemLabel": "labels.elementLock.unlockAll",
"name": "unlockAllElements",
"perform": [Function],
"predicate": [Function],
"trackEvent": Object {
"category": "canvas",
},
"viewMode": false,
},
"separator", "separator",
Object { Object {
"checked": [Function], "checked": [Function],
@ -6043,7 +6053,7 @@ Object {
Object { Object {
"contextItemLabel": [Function], "contextItemLabel": [Function],
"keyTest": [Function], "keyTest": [Function],
"name": "toggleLock", "name": "toggleElementLock",
"perform": [Function], "perform": [Function],
"trackEvent": Object { "trackEvent": Object {
"category": "element", "category": "element",
@ -6389,7 +6399,7 @@ Object {
Object { Object {
"contextItemLabel": [Function], "contextItemLabel": [Function],
"keyTest": [Function], "keyTest": [Function],
"name": "toggleLock", "name": "toggleElementLock",
"perform": [Function], "perform": [Function],
"trackEvent": Object { "trackEvent": Object {
"category": "element", "category": "element",

View File

@ -125,7 +125,7 @@ describe("contextMenu element", () => {
"bringToFront", "bringToFront",
"duplicateSelection", "duplicateSelection",
"hyperlink", "hyperlink",
"toggleLock", "toggleElementLock",
]; ];
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
@ -212,7 +212,7 @@ describe("contextMenu element", () => {
"sendToBack", "sendToBack",
"bringToFront", "bringToFront",
"duplicateSelection", "duplicateSelection",
"toggleLock", "toggleElementLock",
]; ];
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
@ -263,7 +263,7 @@ describe("contextMenu element", () => {
"sendToBack", "sendToBack",
"bringToFront", "bringToFront",
"duplicateSelection", "duplicateSelection",
"toggleLock", "toggleElementLock",
]; ];
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
@ -287,7 +287,7 @@ describe("contextMenu element", () => {
}); });
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
expect(copiedStyles).toBe("{}"); expect(copiedStyles).toBe("{}");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
expect(copiedStyles).not.toBe("{}"); expect(copiedStyles).not.toBe("{}");
const element = JSON.parse(copiedStyles)[0]; const element = JSON.parse(copiedStyles)[0];
expect(element).toEqual(API.getSelectedElement()); expect(element).toEqual(API.getSelectedElement());
@ -328,7 +328,7 @@ describe("contextMenu element", () => {
clientY: 40, clientY: 40,
}); });
let contextMenu = UI.queryContextMenu(); let contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!); fireEvent.click(queryByText(contextMenu!, "Copy styles")!);
const secondRect = JSON.parse(copiedStyles)[0]; const secondRect = JSON.parse(copiedStyles)[0];
expect(secondRect.id).toBe(h.elements[1].id); expect(secondRect.id).toBe(h.elements[1].id);
@ -340,7 +340,7 @@ describe("contextMenu element", () => {
clientY: 10, clientY: 10,
}); });
contextMenu = UI.queryContextMenu(); contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!); fireEvent.click(queryByText(contextMenu!, "Paste styles")!);
const firstRect = API.getSelectedElement(); const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id); expect(firstRect.id).toBe(h.elements[0].id);
@ -364,7 +364,7 @@ describe("contextMenu element", () => {
clientY: 1, clientY: 1,
}); });
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
fireEvent.click(queryAllByText(contextMenu as HTMLElement, "Delete")[0]); fireEvent.click(queryAllByText(contextMenu!, "Delete")[0]);
expect(API.getSelectedElements()).toHaveLength(0); expect(API.getSelectedElements()).toHaveLength(0);
expect(h.elements[0].isDeleted).toBe(true); expect(h.elements[0].isDeleted).toBe(true);
}); });
@ -380,7 +380,7 @@ describe("contextMenu element", () => {
clientY: 1, clientY: 1,
}); });
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!); fireEvent.click(queryByText(contextMenu!, "Add to library")!);
await waitFor(() => { await waitFor(() => {
const library = localStorage.getItem("excalidraw-library"); const library = localStorage.getItem("excalidraw-library");
@ -401,7 +401,7 @@ describe("contextMenu element", () => {
clientY: 1, clientY: 1,
}); });
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!); fireEvent.click(queryByText(contextMenu!, "Duplicate")!);
expect(h.elements).toHaveLength(2); expect(h.elements).toHaveLength(2);
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0]; 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]; 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 contextMenu = UI.queryContextMenu();
const elementsBefore = h.elements; 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[0].id).toEqual(h.elements[1].id);
expect(elementsBefore[1].id).toEqual(h.elements[0].id); expect(elementsBefore[1].id).toEqual(h.elements[0].id);
}); });
@ -447,7 +447,7 @@ describe("contextMenu element", () => {
}); });
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
const elementsBefore = h.elements; 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[0].id).toEqual(h.elements[1].id);
expect(elementsBefore[1].id).toEqual(h.elements[0].id); expect(elementsBefore[1].id).toEqual(h.elements[0].id);
}); });
@ -469,7 +469,7 @@ describe("contextMenu element", () => {
}); });
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
const elementsBefore = h.elements; 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); expect(elementsBefore[1].id).toEqual(h.elements[0].id);
}); });
@ -490,7 +490,7 @@ describe("contextMenu element", () => {
}); });
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
const elementsBefore = h.elements; 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); expect(elementsBefore[0].id).toEqual(h.elements[1].id);
}); });
@ -514,9 +514,7 @@ describe("contextMenu element", () => {
clientY: 1, clientY: 1,
}); });
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
fireEvent.click( fireEvent.click(queryByText(contextMenu!, "Group selection")!);
queryByText(contextMenu as HTMLElement, "Group selection")!,
);
const selectedGroupIds = Object.keys(h.state.selectedGroupIds); const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
expect(h.elements[0].groupIds).toEqual(selectedGroupIds); expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
expect(h.elements[1].groupIds).toEqual(selectedGroupIds); expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
@ -548,9 +546,7 @@ describe("contextMenu element", () => {
const contextMenu = UI.queryContextMenu(); const contextMenu = UI.queryContextMenu();
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
fireEvent.click( fireEvent.click(queryByText(contextMenu!, "Ungroup selection")!);
queryByText(contextMenu as HTMLElement, "Ungroup selection")!,
);
const selectedGroupIds = Object.keys(h.state.selectedGroupIds); const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
expect(selectedGroupIds).toHaveLength(0); expect(selectedGroupIds).toHaveLength(0);

View File

@ -152,7 +152,7 @@ describe("element locking", () => {
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();
expect( expect(
contextMenu?.querySelector( contextMenu?.querySelector(
`li[data-testid="toggleLock"] .context-menu-item__label`, `li[data-testid="toggleElementLock"] .context-menu-item__label`,
), ),
).toHaveTextContent(t("labels.elementLock.unlock")); ).toHaveTextContent(t("labels.elementLock.unlock"));
}); });

View File

@ -321,6 +321,6 @@ export class UI {
static queryContextMenu = () => { static queryContextMenu = () => {
return GlobalTestState.renderResult.container.querySelector( return GlobalTestState.renderResult.container.querySelector(
".context-menu", ".context-menu",
); ) as HTMLElement | null;
}; };
} }