Browse Source

feat: Element locking (#4964)

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Zsolt Viczian <viczian.zsolt@gmail.com>
Tom Sherman 3 years ago
parent
commit
327ed0e2d1

+ 2 - 1
src/actions/actionSelectAll.ts

@@ -18,7 +18,8 @@ export const actionSelectAll = register({
           selectedElementIds: elements.reduce((map, element) => {
             if (
               !element.isDeleted &&
-              !(isTextElement(element) && element.containerId)
+              !(isTextElement(element) && element.containerId) &&
+              element.locked === false
             ) {
               map[element.id] = true;
             }

+ 63 - 0
src/actions/actionToggleLock.ts

@@ -0,0 +1,63 @@
+import { newElementWith } from "../element/mutateElement";
+import { ExcalidrawElement } from "../element/types";
+import { KEYS } from "../keys";
+import { getSelectedElements } from "../scene";
+import { arrayToMap } from "../utils";
+import { register } from "./register";
+
+export const actionToggleLock = register({
+  name: "toggleLock",
+  trackEvent: { category: "element" },
+  perform: (elements, appState) => {
+    const selectedElements = getSelectedElements(elements, appState, true);
+
+    if (!selectedElements.length) {
+      return false;
+    }
+
+    const operation = getOperation(selectedElements);
+    const selectedElementsMap = arrayToMap(selectedElements);
+
+    return {
+      elements: elements.map((element) => {
+        if (!selectedElementsMap.has(element.id)) {
+          return element;
+        }
+
+        return newElementWith(element, { locked: operation === "lock" });
+      }),
+      appState,
+      commitToHistory: true,
+    };
+  },
+  contextItemLabel: (elements, appState) => {
+    const selected = getSelectedElements(elements, appState, false);
+    if (selected.length === 1) {
+      return selected[0].locked
+        ? "labels.elementLock.unlock"
+        : "labels.elementLock.lock";
+    }
+
+    if (selected.length > 1) {
+      return getOperation(selected) === "lock"
+        ? "labels.elementLock.lockAll"
+        : "labels.elementLock.unlockAll";
+    }
+
+    throw new Error(
+      "Unexpected zero elements to lock/unlock. This should never happen.",
+    );
+  },
+  keyTest: (event, appState, elements) => {
+    return (
+      event.key.toLocaleLowerCase() === KEYS.L &&
+      event[KEYS.CTRL_OR_CMD] &&
+      event.shiftKey &&
+      getSelectedElements(elements, appState, false).length > 0
+    );
+  },
+});
+
+const getOperation = (
+  elements: readonly ExcalidrawElement[],
+): "lock" | "unlock" => (elements.some((el) => !el.locked) ? "lock" : "unlock");

+ 1 - 0
src/actions/index.ts

@@ -84,3 +84,4 @@ export { actionToggleZenMode } from "./actionToggleZenMode";
 export { actionToggleStats } from "./actionToggleStats";
 export { actionUnbindText, actionBindText } from "./actionBoundText";
 export { actionLink } from "../element/Hyperlink";
+export { actionToggleLock } from "./actionToggleLock";

+ 2 - 0
src/actions/shortcuts.ts

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

+ 2 - 1
src/actions/types.ts

@@ -111,7 +111,8 @@ export type ActionName =
   | "unbindText"
   | "hyperlink"
   | "eraser"
-  | "bindText";
+  | "bindText"
+  | "toggleLock";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];

+ 1 - 0
src/charts.ts

@@ -167,6 +167,7 @@ const commonProps = {
   strokeStyle: "solid",
   strokeWidth: 1,
   verticalAlign: VERTICAL_ALIGN.MIDDLE,
+  locked: false,
 } as const;
 
 const getChartDimentions = (spreadsheet: Spreadsheet) => {

+ 40 - 15
src/components/App.tsx

@@ -31,6 +31,7 @@ import {
   actionBindText,
   actionUngroup,
   actionLink,
+  actionToggleLock,
 } from "../actions";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { ActionManager } from "../actions/manager";
@@ -1134,7 +1135,7 @@ class App extends React.Component<AppProps, AppState> {
       prevState.activeTool !== this.state.activeTool &&
       multiElement != null &&
       isBindingEnabled(this.state) &&
-      isBindingElement(multiElement)
+      isBindingElement(multiElement, false)
     ) {
       maybeBindLinearElement(
         multiElement,
@@ -1546,6 +1547,7 @@ class App extends React.Component<AppProps, AppState> {
       fontFamily: this.state.currentItemFontFamily,
       textAlign: this.state.currentItemTextAlign,
       verticalAlign: DEFAULT_VERTICAL_ALIGN,
+      locked: false,
     });
 
     this.scene.replaceAllElements([
@@ -2126,12 +2128,14 @@ class App extends React.Component<AppProps, AppState> {
         of all hit elements */
       preferSelected?: boolean;
       includeBoundTextElement?: boolean;
+      includeLockedElements?: boolean;
     },
   ): NonDeleted<ExcalidrawElement> | null {
     const allHitElements = this.getElementsAtPosition(
       x,
       y,
       opts?.includeBoundTextElement,
+      opts?.includeLockedElements,
     );
     if (allHitElements.length > 1) {
       if (opts?.preferSelected) {
@@ -2164,14 +2168,19 @@ class App extends React.Component<AppProps, AppState> {
     x: number,
     y: number,
     includeBoundTextElement: boolean = false,
+    includeLockedElements: boolean = false,
   ): NonDeleted<ExcalidrawElement>[] {
-    const elements = includeBoundTextElement
-      ? this.scene.getElements()
-      : this.scene
-          .getElements()
-          .filter(
-            (element) => !(isTextElement(element) && element.containerId),
-          );
+    const elements =
+      includeBoundTextElement && includeLockedElements
+        ? this.scene.getElements()
+        : this.scene
+            .getElements()
+            .filter(
+              (element) =>
+                (includeLockedElements || !element.locked) &&
+                (includeBoundTextElement ||
+                  !(isTextElement(element) && element.containerId)),
+            );
 
     return getElementsAtPosition(elements, (element) =>
       hitTest(element, this.state, x, y),
@@ -2213,7 +2222,7 @@ class App extends React.Component<AppProps, AppState> {
     if (selectedElements.length === 1) {
       if (isTextElement(selectedElements[0])) {
         existingTextElement = selectedElements[0];
-      } else if (isTextBindableContainer(selectedElements[0])) {
+      } else if (isTextBindableContainer(selectedElements[0], false)) {
         container = selectedElements[0];
         existingTextElement = getBoundTextElement(container);
       }
@@ -2233,7 +2242,8 @@ class App extends React.Component<AppProps, AppState> {
         this.scene
           .getElements()
           .filter(
-            (ele) => isTextBindableContainer(ele) && !getBoundTextElement(ele),
+            (ele) =>
+              isTextBindableContainer(ele, false) && !getBoundTextElement(ele),
           ),
         sceneX,
         sceneY,
@@ -2291,6 +2301,7 @@ class App extends React.Component<AppProps, AppState> {
             : DEFAULT_VERTICAL_ALIGN,
           containerId: container?.id ?? undefined,
           groupIds: container?.groupIds ?? [],
+          locked: false,
         });
 
     this.setState({ editingElement: element });
@@ -2597,7 +2608,7 @@ class App extends React.Component<AppProps, AppState> {
       // Hovering with a selected tool or creating new linear element via click
       // and point
       const { draggingElement } = this.state;
-      if (isBindingElement(draggingElement)) {
+      if (isBindingElement(draggingElement, false)) {
         this.maybeSuggestBindingsForLinearElementAtCoords(
           draggingElement,
           [scenePointer],
@@ -2780,7 +2791,8 @@ class App extends React.Component<AppProps, AppState> {
           this.isHittingCommonBoundingBoxOfSelectedElements(
             scenePointer,
             selectedElements,
-          ))
+          )) &&
+        !hitElement?.locked
       ) {
         setCursor(this.canvas, CURSOR_TYPE.MOVE);
       } else {
@@ -2796,6 +2808,10 @@ class App extends React.Component<AppProps, AppState> {
   ) => {
     const updateElementIds = (elements: ExcalidrawElement[]) => {
       elements.forEach((element) => {
+        if (element.locked) {
+          return;
+        }
+
         idsToUpdate.push(element.id);
         if (event.altKey) {
           if (
@@ -3617,6 +3633,7 @@ class App extends React.Component<AppProps, AppState> {
       opacity: this.state.currentItemOpacity,
       strokeSharpness: this.state.currentItemLinearStrokeSharpness,
       simulatePressure: event.pressure === 0.5,
+      locked: false,
     });
 
     this.setState((prevState) => ({
@@ -3672,6 +3689,7 @@ class App extends React.Component<AppProps, AppState> {
       roughness: this.state.currentItemRoughness,
       opacity: this.state.currentItemOpacity,
       strokeSharpness: this.state.currentItemLinearStrokeSharpness,
+      locked: false,
     });
 
     return element;
@@ -3759,6 +3777,7 @@ class App extends React.Component<AppProps, AppState> {
         strokeSharpness: this.state.currentItemLinearStrokeSharpness,
         startArrowhead,
         endArrowhead,
+        locked: false,
       });
       this.setState((prevState) => ({
         selectedElementIds: {
@@ -3807,6 +3826,7 @@ class App extends React.Component<AppProps, AppState> {
       roughness: this.state.currentItemRoughness,
       opacity: this.state.currentItemOpacity,
       strokeSharpness: this.state.currentItemStrokeSharpness,
+      locked: false,
     });
 
     if (element.type === "selection") {
@@ -4106,7 +4126,7 @@ class App extends React.Component<AppProps, AppState> {
           });
         }
 
-        if (isBindingElement(draggingElement)) {
+        if (isBindingElement(draggingElement, false)) {
           // When creating a linear element by dragging
           this.maybeSuggestBindingsForLinearElementAtCoords(
             draggingElement,
@@ -4385,7 +4405,7 @@ class App extends React.Component<AppProps, AppState> {
         } else if (pointerDownState.drag.hasOccurred && !multiElement) {
           if (
             isBindingEnabled(this.state) &&
-            isBindingElement(draggingElement)
+            isBindingElement(draggingElement, false)
           ) {
             maybeBindLinearElement(
               draggingElement,
@@ -5303,7 +5323,10 @@ class App extends React.Component<AppProps, AppState> {
     }
 
     const { x, y } = viewportCoordsToSceneCoords(event, this.state);
-    const element = this.getElementAtPosition(x, y, { preferSelected: true });
+    const element = this.getElementAtPosition(x, y, {
+      preferSelected: true,
+      includeLockedElements: true,
+    });
 
     const type = element ? "element" : "canvas";
 
@@ -5615,6 +5638,8 @@ class App extends React.Component<AppProps, AppState> {
             (maybeFlipHorizontal || maybeFlipVertical) && separator,
             actionLink.contextItemPredicate(elements, this.state) && actionLink,
             actionDuplicateSelection,
+            actionToggleLock,
+            separator,
             actionDeleteSelected,
           ],
           top,

+ 4 - 0
src/components/HelpDialog.tsx

@@ -364,6 +364,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
                   ]}
                 />
                 <Shortcut
+                  label={t("helpDialog.toggleElementLock")}
+                  shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]}
+                />
+                <Shortcut
                   label={t("buttons.undo")}
                   shortcuts={[getShortcutKey("CtrlOrCmd+Z")]}
                 />

+ 1 - 0
src/data/restore.ts

@@ -107,6 +107,7 @@ const restoreElementWithProperties = <
       : element.boundElements ?? [],
     updated: element.updated ?? getUpdatedTimestamp(),
     link: element.link ?? null,
+    locked: element.locked ?? false,
   };
 
   return {

+ 5 - 4
src/element/binding.ts

@@ -255,7 +255,8 @@ export const getHoveredElementForBinding = (
   const hoveredElement = getElementAtPosition(
     scene.getElements(),
     (element) =>
-      isBindableElement(element) && bindingBorderTest(element, pointerCoords),
+      isBindableElement(element, false) &&
+      bindingBorderTest(element, pointerCoords),
   );
   return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
 };
@@ -456,13 +457,13 @@ export const getEligibleElementsForBinding = (
 ): SuggestedBinding[] => {
   const includedElementIds = new Set(elements.map(({ id }) => id));
   return elements.flatMap((element) =>
-    isBindingElement(element)
+    isBindingElement(element, false)
       ? (getElligibleElementsForBindingElement(
           element as NonDeleted<ExcalidrawLinearElement>,
         ).filter(
           (element) => !includedElementIds.has(element.id),
         ) as SuggestedBinding[])
-      : isBindableElement(element)
+      : isBindableElement(element, false)
       ? getElligibleElementsForBindableElementAndWhere(element).filter(
           (binding) => !includedElementIds.has(binding[0].id),
         )
@@ -508,7 +509,7 @@ const getElligibleElementsForBindableElementAndWhere = (
   return Scene.getScene(bindableElement)!
     .getElements()
     .map((element) => {
-      if (!isBindingElement(element)) {
+      if (!isBindingElement(element, false)) {
         return null;
       }
       const canBindStart = isLinearElementEligibleForNewBindingByBindable(

+ 1 - 1
src/element/linearElementEditor.ts

@@ -205,7 +205,7 @@ export class LinearElementEditor {
       );
 
       // suggest bindings for first and last point if selected
-      if (isBindingElement(element)) {
+      if (isBindingElement(element, false)) {
         const coords: { x: number; y: number }[] = [];
 
         const firstSelectedIndex = selectedPointsIndices[0];

+ 2 - 0
src/element/newElement.ts

@@ -56,6 +56,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     strokeSharpness,
     boundElements = null,
     link = null,
+    locked,
     ...rest
   }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
 ) => {
@@ -83,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     boundElements,
     updated: getUpdatedTimestamp(),
     link,
+    locked,
   };
   return element;
 };

+ 7 - 0
src/element/transformHandles.ts

@@ -222,6 +222,13 @@ export const getTransformHandles = (
   zoom: Zoom,
   pointerType: PointerType = "mouse",
 ): TransformHandles => {
+  // so that when locked element is selected (especially when you toggle lock
+  // via keyboard) the locked element is visually distinct, indicating
+  // you can't move/resize
+  if (element.locked) {
+    return {};
+  }
+
   let omitSides: { [T in TransformHandleType]?: boolean } = {};
   if (
     element.type === "arrow" ||

+ 10 - 1
src/element/typeChecks.ts

@@ -70,8 +70,13 @@ export const isLinearElementType = (
 
 export const isBindingElement = (
   element?: ExcalidrawElement | null,
+  includeLocked = true,
 ): element is ExcalidrawLinearElement => {
-  return element != null && isBindingElementType(element.type);
+  return (
+    element != null &&
+    (!element.locked || includeLocked === true) &&
+    isBindingElementType(element.type)
+  );
 };
 
 export const isBindingElementType = (
@@ -82,9 +87,11 @@ export const isBindingElementType = (
 
 export const isBindableElement = (
   element: ExcalidrawElement | null,
+  includeLocked = true,
 ): element is ExcalidrawBindableElement => {
   return (
     element != null &&
+    (!element.locked || includeLocked === true) &&
     (element.type === "rectangle" ||
       element.type === "diamond" ||
       element.type === "ellipse" ||
@@ -95,9 +102,11 @@ export const isBindableElement = (
 
 export const isTextBindableContainer = (
   element: ExcalidrawElement | null,
+  includeLocked = true,
 ): element is ExcalidrawTextContainer => {
   return (
     element != null &&
+    (!element.locked || includeLocked === true) &&
     (element.type === "rectangle" ||
       element.type === "diamond" ||
       element.type === "ellipse" ||

+ 1 - 0
src/element/types.ts

@@ -55,6 +55,7 @@ type _ExcalidrawElementBase = Readonly<{
   /** epoch (ms) timestamp of last element update */
   updated: number;
   link: string | null;
+  locked: boolean;
 }>;
 
 export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {

+ 8 - 2
src/locales/en.json

@@ -113,6 +113,12 @@
       "edit": "Edit link",
       "create": "Create link",
       "label": "Link"
+    },
+    "elementLock": {
+      "lock": "Lock",
+      "unlock": "Unlock",
+      "lockAll": "Lock all",
+      "unlockAll": "Unlock all"
     }
   },
   "buttons": {
@@ -292,7 +298,8 @@
     "title": "Help",
     "view": "View",
     "zoomToFit": "Zoom to fit all elements",
-    "zoomToSelection": "Zoom to selection"
+    "zoomToSelection": "Zoom to selection",
+    "toggleElementLock": "Lock/unlock selection"
   },
   "clearCanvasDialog": {
     "title": "Clear canvas"
@@ -336,7 +343,6 @@
     "noteItems": "Each library item must have its own name so it's filterable. The following library items will be included:",
     "atleastOneLibItem": "Please select at least one library item to get started"
   },
-
   "publishSuccessDialog": {
     "title": "Library submitted",
     "content": "Thank you {{authorName}}. Your library has been submitted for review. You can track the status",

+ 1 - 1
src/scene/comparisons.ts

@@ -91,5 +91,5 @@ export const getTextBindableContainerAtPosition = (
       break;
     }
   }
-  return isTextBindableContainer(hitElement) ? hitElement : null;
+  return isTextBindableContainer(hitElement, false) ? hitElement : null;
 };

+ 1 - 0
src/scene/selection.ts

@@ -17,6 +17,7 @@ export const getElementsWithinSelection = (
       getElementBounds(element);
 
     return (
+      element.locked === false &&
       element.type !== "selection" &&
       !isBoundToContainer(element) &&
       selectionX1 <= elementX1 &&

+ 93 - 0
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -94,6 +94,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -149,6 +150,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -268,6 +270,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -296,6 +299,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -351,6 +355,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -390,6 +395,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -415,6 +421,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -454,6 +461,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -479,6 +487,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -598,6 +607,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -626,6 +636,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -681,6 +692,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -720,6 +732,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -745,6 +758,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -784,6 +798,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -809,6 +824,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -928,6 +944,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 449462985,
@@ -983,6 +1000,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -1100,6 +1118,7 @@ Object {
   "id": "id0",
   "isDeleted": true,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -1155,6 +1174,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1192,6 +1212,7 @@ Object {
           "id": "id0",
           "isDeleted": true,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1311,6 +1332,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -1339,6 +1361,7 @@ Object {
   "id": "id0_copy",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -1394,6 +1417,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1433,6 +1457,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1458,6 +1483,7 @@ Object {
           "id": "id0_copy",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -1585,6 +1611,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -1615,6 +1642,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -1670,6 +1698,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1709,6 +1738,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1734,6 +1764,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -1779,6 +1810,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1806,6 +1838,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -1925,6 +1958,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 60,
   "roughness": 2,
   "seed": 1278240551,
@@ -1953,6 +1987,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 60,
   "roughness": 2,
   "seed": 400692809,
@@ -2008,6 +2043,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2047,6 +2083,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2072,6 +2109,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2111,6 +2149,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2136,6 +2175,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2175,6 +2215,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2200,6 +2241,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2239,6 +2281,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2264,6 +2307,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2303,6 +2347,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2328,6 +2373,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2367,6 +2413,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2392,6 +2439,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2431,6 +2479,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2456,6 +2505,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 2,
           "seed": 400692809,
@@ -2495,6 +2545,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2520,6 +2571,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 60,
           "roughness": 2,
           "seed": 400692809,
@@ -2559,6 +2611,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 60,
           "roughness": 2,
           "seed": 1278240551,
@@ -2584,6 +2637,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 60,
           "roughness": 2,
           "seed": 400692809,
@@ -2703,6 +2757,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -2731,6 +2786,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -2786,6 +2842,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2825,6 +2882,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2850,6 +2908,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2889,6 +2948,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2914,6 +2974,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3033,6 +3094,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -3061,6 +3123,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -3116,6 +3179,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3155,6 +3219,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3180,6 +3245,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -3219,6 +3285,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -3244,6 +3311,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3367,6 +3435,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 449462985,
@@ -3395,6 +3464,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 401146281,
@@ -3450,6 +3520,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -3489,6 +3560,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -3514,6 +3586,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -3559,6 +3632,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -3586,6 +3660,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -3627,6 +3702,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -3652,6 +3728,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -3777,6 +3854,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -3805,6 +3883,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -3860,6 +3939,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3899,6 +3979,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3924,6 +4005,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -4053,6 +4135,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 449462985,
@@ -4083,6 +4166,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 401146281,
@@ -4138,6 +4222,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -4177,6 +4262,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -4202,6 +4288,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -4248,6 +4335,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -4275,6 +4363,7 @@ Object {
           "id": "id1",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -4582,6 +4671,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -4610,6 +4700,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -4638,6 +4729,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -4693,6 +4785,7 @@ Object {
           "id": "id0",
           "isDeleted": false,
           "link": null,
+          "locked": false,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,

+ 5 - 0
src/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -16,6 +16,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [
@@ -58,6 +59,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -88,6 +90,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -119,6 +122,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [
@@ -161,6 +165,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,

+ 6 - 0
src/tests/__snapshots__/move.test.tsx.snap

@@ -11,6 +11,7 @@ Object {
   "id": "id0_copy",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 401146281,
@@ -39,6 +40,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -67,6 +69,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -100,6 +103,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -133,6 +137,7 @@ Object {
   "id": "id1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 449462985,
@@ -168,6 +173,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [

+ 2 - 0
src/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -17,6 +17,7 @@ Object {
     110,
   ],
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [
@@ -67,6 +68,7 @@ Object {
     110,
   ],
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [

File diff suppressed because it is too large
+ 126 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 5 - 0
src/tests/__snapshots__/selection.test.tsx.snap

@@ -14,6 +14,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [
@@ -57,6 +58,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [
@@ -97,6 +99,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -125,6 +128,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -153,6 +157,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,

+ 21 - 22
src/tests/contextmenu.test.tsx

@@ -36,10 +36,6 @@ const checkpoint = (name: string) => {
 
 const mouse = new Pointer("mouse");
 
-const queryContextMenu = () => {
-  return GlobalTestState.renderResult.container.querySelector(".context-menu");
-};
-
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
@@ -83,7 +79,7 @@ describe("contextMenu element", () => {
       clientX: 1,
       clientY: 1,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     const contextMenuOptions =
       contextMenu?.querySelectorAll(".context-menu li");
     const expectedShortcutNames: ShortcutName[] = [
@@ -113,7 +109,7 @@ describe("contextMenu element", () => {
       clientX: 1,
       clientY: 1,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     const contextMenuOptions =
       contextMenu?.querySelectorAll(".context-menu li");
     const expectedShortcutNames: ShortcutName[] = [
@@ -129,6 +125,7 @@ describe("contextMenu element", () => {
       "bringToFront",
       "duplicateSelection",
       "hyperlink",
+      "toggleLock",
     ];
 
     expect(contextMenu).not.toBeNull();
@@ -166,7 +163,7 @@ describe("contextMenu element", () => {
       clientX: 100,
       clientY: 100,
     });
-    expect(queryContextMenu()).not.toBeNull();
+    expect(UI.queryContextMenu()).not.toBeNull();
     expect(API.getSelectedElement().id).toBe(rect1.id);
 
     // higher z-index
@@ -176,7 +173,7 @@ describe("contextMenu element", () => {
       clientX: 100,
       clientY: 100,
     });
-    expect(queryContextMenu()).not.toBeNull();
+    expect(UI.queryContextMenu()).not.toBeNull();
     expect(API.getSelectedElement().id).toBe(rect2.id);
   });
 
@@ -201,7 +198,7 @@ describe("contextMenu element", () => {
       clientY: 1,
     });
 
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     const contextMenuOptions =
       contextMenu?.querySelectorAll(".context-menu li");
     const expectedShortcutNames: ShortcutName[] = [
@@ -215,6 +212,7 @@ describe("contextMenu element", () => {
       "sendToBack",
       "bringToFront",
       "duplicateSelection",
+      "toggleLock",
     ];
 
     expect(contextMenu).not.toBeNull();
@@ -251,7 +249,7 @@ describe("contextMenu element", () => {
       clientY: 1,
     });
 
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     const contextMenuOptions =
       contextMenu?.querySelectorAll(".context-menu li");
     const expectedShortcutNames: ShortcutName[] = [
@@ -265,6 +263,7 @@ describe("contextMenu element", () => {
       "sendToBack",
       "bringToFront",
       "duplicateSelection",
+      "toggleLock",
     ];
 
     expect(contextMenu).not.toBeNull();
@@ -286,7 +285,7 @@ describe("contextMenu element", () => {
       clientX: 1,
       clientY: 1,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     expect(copiedStyles).toBe("{}");
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
     expect(copiedStyles).not.toBe("{}");
@@ -328,7 +327,7 @@ describe("contextMenu element", () => {
       clientX: 40,
       clientY: 40,
     });
-    let contextMenu = queryContextMenu();
+    let contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
     const secondRect = JSON.parse(copiedStyles);
     expect(secondRect.id).toBe(h.elements[1].id);
@@ -340,7 +339,7 @@ describe("contextMenu element", () => {
       clientX: 10,
       clientY: 10,
     });
-    contextMenu = queryContextMenu();
+    contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
 
     const firstRect = API.getSelectedElement();
@@ -364,7 +363,7 @@ describe("contextMenu element", () => {
       clientX: 1,
       clientY: 1,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     fireEvent.click(queryAllByText(contextMenu as HTMLElement, "Delete")[0]);
     expect(API.getSelectedElements()).toHaveLength(0);
     expect(h.elements[0].isDeleted).toBe(true);
@@ -380,7 +379,7 @@ describe("contextMenu element", () => {
       clientX: 1,
       clientY: 1,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
 
     await waitFor(() => {
@@ -401,7 +400,7 @@ describe("contextMenu element", () => {
       clientX: 1,
       clientY: 1,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
     expect(h.elements).toHaveLength(2);
     const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
@@ -424,7 +423,7 @@ describe("contextMenu element", () => {
       clientX: 40,
       clientY: 40,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     const elementsBefore = h.elements;
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Send backward")!);
     expect(elementsBefore[0].id).toEqual(h.elements[1].id);
@@ -446,7 +445,7 @@ describe("contextMenu element", () => {
       clientX: 10,
       clientY: 10,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     const elementsBefore = h.elements;
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring forward")!);
     expect(elementsBefore[0].id).toEqual(h.elements[1].id);
@@ -468,7 +467,7 @@ describe("contextMenu element", () => {
       clientX: 40,
       clientY: 40,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     const elementsBefore = h.elements;
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Send to back")!);
     expect(elementsBefore[1].id).toEqual(h.elements[0].id);
@@ -489,7 +488,7 @@ describe("contextMenu element", () => {
       clientX: 10,
       clientY: 10,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     const elementsBefore = h.elements;
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring to front")!);
     expect(elementsBefore[0].id).toEqual(h.elements[1].id);
@@ -514,7 +513,7 @@ describe("contextMenu element", () => {
       clientX: 1,
       clientY: 1,
     });
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     fireEvent.click(
       queryByText(contextMenu as HTMLElement, "Group selection")!,
     );
@@ -547,7 +546,7 @@ describe("contextMenu element", () => {
       clientY: 1,
     });
 
-    const contextMenu = queryContextMenu();
+    const contextMenu = UI.queryContextMenu();
     expect(contextMenu).not.toBeNull();
     fireEvent.click(
       queryByText(contextMenu as HTMLElement, "Ungroup selection")!,

+ 9 - 0
src/tests/data/__snapshots__/restore.test.ts.snap

@@ -14,6 +14,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [
@@ -58,6 +59,7 @@ Object {
   "id": "1",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 10,
   "roughness": 2,
   "seed": Any<Number>,
@@ -90,6 +92,7 @@ Object {
   "id": "2",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 10,
   "roughness": 2,
   "seed": Any<Number>,
@@ -122,6 +125,7 @@ Object {
   "id": "3",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 10,
   "roughness": 2,
   "seed": Any<Number>,
@@ -151,6 +155,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [],
   "pressures": Array [],
@@ -185,6 +190,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [
@@ -228,6 +234,7 @@ Object {
   "isDeleted": false,
   "lastCommittedPoint": null,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "points": Array [
     Array [
@@ -272,6 +279,7 @@ Object {
   "id": "id-text01",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "originalText": "text",
   "roughness": 1,
@@ -308,6 +316,7 @@ Object {
   "id": "id-text01",
   "isDeleted": false,
   "link": null,
+  "locked": false,
   "opacity": 100,
   "originalText": "test",
   "roughness": 1,

+ 388 - 0
src/tests/elementLocking.test.tsx

@@ -0,0 +1,388 @@
+import ReactDOM from "react-dom";
+import ExcalidrawApp from "../excalidraw-app";
+import { render } from "../tests/test-utils";
+import { Keyboard, Pointer, UI } from "../tests/helpers/ui";
+import { KEYS } from "../keys";
+import { API } from "../tests/helpers/api";
+import { actionSelectAll } from "../actions";
+import { t } from "../i18n";
+import { mutateElement } from "../element/mutateElement";
+
+ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
+
+const mouse = new Pointer("mouse");
+const h = window.h;
+
+describe("element locking", () => {
+  beforeEach(async () => {
+    await render(<ExcalidrawApp />);
+    h.elements = [];
+  });
+
+  it("click-selecting a locked element is disabled", () => {
+    const lockedRectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      locked: true,
+    });
+
+    h.elements = [lockedRectangle];
+
+    mouse.clickAt(50, 50);
+    expect(API.getSelectedElements().length).toBe(0);
+  });
+
+  it("box-selecting a locked element is disabled", () => {
+    const lockedRectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      locked: true,
+      x: 100,
+      y: 100,
+    });
+
+    h.elements = [lockedRectangle];
+
+    mouse.downAt(50, 50);
+    mouse.moveTo(250, 250);
+    mouse.upAt(250, 250);
+    expect(API.getSelectedElements().length).toBe(0);
+  });
+
+  it("dragging a locked element is disabled", () => {
+    const lockedRectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      locked: true,
+    });
+
+    h.elements = [lockedRectangle];
+
+    mouse.downAt(50, 50);
+    mouse.moveTo(100, 100);
+    mouse.upAt(100, 100);
+    expect(lockedRectangle).toEqual(expect.objectContaining({ x: 0, y: 0 }));
+  });
+
+  it("you can drag element that's below a locked element", () => {
+    const rectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+    });
+    const lockedRectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      locked: true,
+    });
+
+    h.elements = [rectangle, lockedRectangle];
+
+    mouse.downAt(50, 50);
+    mouse.moveTo(100, 100);
+    mouse.upAt(100, 100);
+    expect(lockedRectangle).toEqual(expect.objectContaining({ x: 0, y: 0 }));
+    expect(rectangle).toEqual(expect.objectContaining({ x: 50, y: 50 }));
+    expect(API.getSelectedElements().length).toBe(1);
+    expect(API.getSelectedElement().id).toBe(rectangle.id);
+  });
+
+  it("selectAll shouldn't select locked elements", () => {
+    h.elements = [
+      API.createElement({ type: "rectangle" }),
+      API.createElement({ type: "rectangle", locked: true }),
+    ];
+    h.app.actionManager.executeAction(actionSelectAll);
+    expect(API.getSelectedElements().length).toBe(1);
+  });
+
+  it("clicking on a locked element should select the unlocked element beneath it", () => {
+    const rectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+    });
+    const lockedRectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      locked: true,
+    });
+
+    h.elements = [rectangle, lockedRectangle];
+    expect(API.getSelectedElements().length).toBe(0);
+    mouse.clickAt(50, 50);
+    expect(API.getSelectedElements().length).toBe(1);
+    expect(API.getSelectedElement().id).toBe(rectangle.id);
+  });
+
+  it("right-clicking on a locked element should select it & open its contextMenu", () => {
+    const rectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+    });
+    const lockedRectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      locked: true,
+    });
+
+    h.elements = [rectangle, lockedRectangle];
+    expect(API.getSelectedElements().length).toBe(0);
+    mouse.rightClickAt(50, 50);
+    expect(API.getSelectedElements().length).toBe(1);
+    expect(API.getSelectedElement().id).toBe(lockedRectangle.id);
+
+    const contextMenu = UI.queryContextMenu();
+    expect(contextMenu).not.toBeNull();
+    expect(
+      contextMenu?.querySelector(
+        `li[data-testid="toggleLock"] .context-menu-option__label`,
+      ),
+    ).toHaveTextContent(t("labels.elementLock.unlock"));
+  });
+
+  it("right-clicking on element covered by locked element should ignore the locked element", () => {
+    const rectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+    });
+    const lockedRectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      locked: true,
+    });
+
+    h.elements = [rectangle, lockedRectangle];
+    API.setSelectedElements([rectangle]);
+    expect(API.getSelectedElements().length).toBe(1);
+    expect(API.getSelectedElement().id).toBe(rectangle.id);
+    mouse.rightClickAt(50, 50);
+    expect(API.getSelectedElements().length).toBe(1);
+    expect(API.getSelectedElement().id).toBe(rectangle.id);
+
+    const contextMenu = UI.queryContextMenu();
+    expect(contextMenu).not.toBeNull();
+  });
+
+  it("selecting a group selects all elements including locked ones", () => {
+    const rectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      groupIds: ["g1"],
+    });
+    const lockedRectangle = API.createElement({
+      type: "rectangle",
+      width: 100,
+      backgroundColor: "red",
+      fillStyle: "solid",
+      locked: true,
+      groupIds: ["g1"],
+      x: 200,
+      y: 200,
+    });
+
+    h.elements = [rectangle, lockedRectangle];
+
+    mouse.clickAt(250, 250);
+    expect(API.getSelectedElements().length).toBe(0);
+
+    mouse.clickAt(50, 50);
+    expect(API.getSelectedElements().length).toBe(2);
+  });
+
+  it("should ignore locked text element in center of container on ENTER", () => {
+    const container = API.createElement({
+      type: "rectangle",
+      width: 100,
+    });
+    const textSize = 20;
+    const text = API.createElement({
+      type: "text",
+      text: "ola",
+      x: container.width / 2 - textSize / 2,
+      y: container.height / 2 - textSize / 2,
+      width: textSize,
+      height: textSize,
+      containerId: container.id,
+      locked: true,
+    });
+    h.elements = [container, text];
+    API.setSelectedElements([container]);
+    Keyboard.keyPress(KEYS.ENTER);
+    expect(h.state.editingElement?.id).not.toBe(text.id);
+    expect(h.state.editingElement?.id).toBe(h.elements[2].id);
+  });
+
+  it("should ignore locked text under cursor when clicked with text tool", () => {
+    const text = API.createElement({
+      type: "text",
+      text: "ola",
+      x: 60,
+      y: 0,
+      width: 100,
+      height: 100,
+      locked: true,
+    });
+    h.elements = [text];
+    UI.clickTool("text");
+    mouse.clickAt(text.x + 50, text.y + 50);
+    const editor = document.querySelector(
+      ".excalidraw-textEditorContainer > textarea",
+    ) as HTMLTextAreaElement;
+    expect(editor).not.toBe(null);
+    expect(h.state.editingElement?.id).not.toBe(text.id);
+    expect(h.elements.length).toBe(2);
+    expect(h.state.editingElement?.id).toBe(h.elements[1].id);
+  });
+
+  it("should ignore text under cursor when double-clicked with selection tool", () => {
+    const text = API.createElement({
+      type: "text",
+      text: "ola",
+      x: 60,
+      y: 0,
+      width: 100,
+      height: 100,
+      locked: true,
+    });
+    h.elements = [text];
+    UI.clickTool("selection");
+    mouse.doubleClickAt(text.x + 50, text.y + 50);
+    const editor = document.querySelector(
+      ".excalidraw-textEditorContainer > textarea",
+    ) as HTMLTextAreaElement;
+    expect(editor).not.toBe(null);
+    expect(h.state.editingElement?.id).not.toBe(text.id);
+    expect(h.elements.length).toBe(2);
+    expect(h.state.editingElement?.id).toBe(h.elements[1].id);
+  });
+
+  it("locking should include bound text", () => {
+    const container = API.createElement({
+      type: "rectangle",
+      width: 100,
+    });
+    const textSize = 20;
+    const text = API.createElement({
+      type: "text",
+      text: "ola",
+      x: container.width / 2 - textSize / 2,
+      y: container.height / 2 - textSize / 2,
+      width: textSize,
+      height: textSize,
+      containerId: container.id,
+    });
+    mutateElement(container, {
+      boundElements: [{ id: text.id, type: "text" }],
+    });
+
+    h.elements = [container, text];
+
+    UI.clickTool("selection");
+    mouse.clickAt(container.x + 10, container.y + 10);
+    Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+      Keyboard.keyPress(KEYS.L);
+    });
+
+    expect(h.elements).toEqual([
+      expect.objectContaining({
+        id: container.id,
+        locked: true,
+      }),
+      expect.objectContaining({
+        id: text.id,
+        locked: true,
+      }),
+    ]);
+  });
+
+  it("bound text shouldn't be editable via double-click", () => {
+    const container = API.createElement({
+      type: "rectangle",
+      width: 100,
+      locked: true,
+    });
+    const textSize = 20;
+    const text = API.createElement({
+      type: "text",
+      text: "ola",
+      x: container.width / 2 - textSize / 2,
+      y: container.height / 2 - textSize / 2,
+      width: textSize,
+      height: textSize,
+      containerId: container.id,
+      locked: true,
+    });
+    mutateElement(container, {
+      boundElements: [{ id: text.id, type: "text" }],
+    });
+    h.elements = [container, text];
+
+    UI.clickTool("selection");
+    mouse.doubleClickAt(container.width / 2, container.height / 2);
+
+    const editor = document.querySelector(
+      ".excalidraw-textEditorContainer > textarea",
+    ) as HTMLTextAreaElement;
+    expect(editor).not.toBe(null);
+    expect(h.state.editingElement?.id).not.toBe(text.id);
+    expect(h.elements.length).toBe(3);
+    expect(h.state.editingElement?.id).toBe(h.elements[2].id);
+  });
+
+  it("bound text shouldn't be editable via text tool", () => {
+    const container = API.createElement({
+      type: "rectangle",
+      width: 100,
+      locked: true,
+    });
+    const textSize = 20;
+    const text = API.createElement({
+      type: "text",
+      text: "ola",
+      x: container.width / 2 - textSize / 2,
+      y: container.height / 2 - textSize / 2,
+      width: textSize,
+      height: textSize,
+      containerId: container.id,
+      locked: true,
+    });
+    mutateElement(container, {
+      boundElements: [{ id: text.id, type: "text" }],
+    });
+    h.elements = [container, text];
+
+    UI.clickTool("text");
+    mouse.clickAt(container.width / 2, container.height / 2);
+
+    const editor = document.querySelector(
+      ".excalidraw-textEditorContainer > textarea",
+    ) as HTMLTextAreaElement;
+    expect(editor).not.toBe(null);
+    expect(h.state.editingElement?.id).not.toBe(text.id);
+    expect(h.elements.length).toBe(3);
+    expect(h.state.editingElement?.id).toBe(h.elements[2].id);
+  });
+});

+ 1 - 0
src/tests/fixtures/elementFixture.ts

@@ -23,6 +23,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
   boundElements: null,
   updated: 1,
   link: null,
+  locked: false,
 };
 
 export const rectangleFixture: ExcalidrawElement = {

+ 7 - 4
src/tests/helpers/api.ts

@@ -15,6 +15,7 @@ import path from "path";
 import { getMimeType } from "../../data/blob";
 import { newFreeDrawElement } from "../../element/newElement";
 import { Point } from "../../types";
+import { getSelectedElements } from "../../scene/selection";
 
 const readFile = util.promisify(fs.readFile);
 
@@ -30,10 +31,10 @@ export class API {
     });
   };
 
-  static getSelectedElements = (): ExcalidrawElement[] => {
-    return h.elements.filter(
-      (element) => h.state.selectedElementIds[element.id],
-    );
+  static getSelectedElements = (
+    includeBoundTextElement: boolean = false,
+  ): ExcalidrawElement[] => {
+    return getSelectedElements(h.elements, h.state, includeBoundTextElement);
   };
 
   static getSelectedElement = (): ExcalidrawElement => {
@@ -100,6 +101,7 @@ export class API {
       ? ExcalidrawTextElement["containerId"]
       : never;
     points?: T extends "arrow" | "line" ? readonly Point[] : never;
+    locked?: boolean;
   }): T extends "arrow" | "line"
     ? ExcalidrawLinearElement
     : T extends "freedraw"
@@ -125,6 +127,7 @@ export class API {
       roughness: rest.roughness ?? appState.currentItemRoughness,
       opacity: rest.opacity ?? appState.currentItemOpacity,
       boundElements: rest.boundElements ?? null,
+      locked: rest.locked ?? false,
     };
     switch (type) {
       case "rectangle":

+ 14 - 0
src/tests/helpers/ui.ts

@@ -179,6 +179,14 @@ export class Pointer {
     this.upAt();
   }
 
+  rightClickAt(x: number, y: number) {
+    fireEvent.contextMenu(GlobalTestState.canvas, {
+      button: 2,
+      clientX: x,
+      clientY: y,
+    });
+  }
+
   doubleClickAt(x: number, y: number) {
     this.moveTo(x, y);
     fireEvent.doubleClick(GlobalTestState.canvas, this.getEvent());
@@ -309,4 +317,10 @@ export class UI {
       Keyboard.codePress(CODES.G);
     });
   }
+
+  static queryContextMenu = () => {
+    return GlobalTestState.renderResult.container.querySelector(
+      ".context-menu",
+    );
+  };
 }

+ 1 - 1
src/tests/scene/__snapshots__/export.test.ts.snap

@@ -93,7 +93,7 @@ exports[`exportToSvg with elements that have a link 1`] = `
 exports[`exportToSvg with exportEmbedScene 1`] = `
 "
   <!-- svg-source:excalidraw -->
-  <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1STU9cdTAwMDMhXHUwMDEwvfdXbPDapLtrv+ytWmNMjFx1MDAxZXpoovFAl9mFlFx1MDAwMlx1MDAwNbZcdTAwMWZp+t9cdTAwMDXaLrrx5lVcdTAwMGUk83hvZph5x06SIHtQgCZcdIJ9gTkjXHUwMDFh71DX41vQhknhnvJcdTAwMTBcdTAwMWJZ61wiMKm1atLrcelcdTAwMDRUXHUwMDFhe+ZcdTAwMDOHNVxia1x1MDAxY+PDxUlyXGa3e2HEq7ZcdTAwMGK9eZuWKyZIvinWo5fZ9Ok9SFx1MDAwM2nvOP2s38RcdTAwMDdf+HbUxDtGLHVYlqZcckaBVdS2QCwq7tuMiLFaruBBcql9IzdpOLH0XHUwMDEyXHUwMDE3q0rLWpDIyVx1MDAwNlx1MDAxOC/LyClcdTAwMTnnc3vg51x1MDAwMeCC1lx1MDAxYVCrwuLaYlx1MDAwYm90RrpcdTAwMDFHlStZUVx1MDAwMcb80EiFXHUwMDBiZlx1MDAwZq1f+f7UM1x00/1s56dYq0tcdTAwMWVkfPCtM1x1MDAwMFx1MDAxMlL1s+FgdJeOm5e43yxP2+irXHUwMDE0YddZNlx1MDAxZadpP1x1MDAxZlxyXHUwMDFiXHUwMDA2MzO3alx1MDAxYtKWmFx1MDAxYohz9CN8jDZcdTAwMTA1581jrVxiPoviVzlcdTAwMTOrNu9qR8LwWlxuglx1MDAwMn7q/jvq31F/dFx1MDAxNHDOlIGLo9xcdTAwMWR+jbBSc+tcdTAwMTI5ytlfaMtgd//LXHUwMDA2y3C8PvjRb1x1MDAxMHxXx1Pn9Fx1MDAwNbeWWs0ifQ==<!-- payload-end -->
+  <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1SPW/CMFx1MDAxMN35XHUwMDE1kbtcIpGk4aNstFRVpapcdTAwMWRcdTAwMTiQWnUw8YVYMbaxXHUwMDFkPoT477VccsRtxNpcclx1MDAwZpbu+b278907dKJcYpm9XHUwMDA0NI5cdTAwMTDscswoUXiLulx1MDAwZd+A0lRw+5T6WIta5Z5ZXHUwMDFhI8e9XHUwMDFlXHUwMDEzVlBcbm1OfGCwXHUwMDAybrRlfNk4ilx1MDAwZf62L5Q41Wau1lx1MDAxZpOiopyk63w1fJtOXj691JN2lpMlWVx1MDAxM+9d4fthXHUwMDEzbykxpcWSOG6wXHUwMDEy6LI0LVx1MDAxMPMlc21cdTAwMDZEXHUwMDFiJSp4XHUwMDEyTCjXyF3sTyi9wHm1VKLmJHCSPsaLXCJwXG7K2Mzs2WlcdTAwMDA4L2tcdTAwMDWoVWF+abGFNzot7ICDypZcXJZcdTAwMWO0/qNcdTAwMTFcdTAwMTLn1Oxbv3L9yVfip/vdzl9iJc95kHbBr85cdTAwMDCIT5Ulg/7wIVx1MDAxZTUvYb9JXHUwMDFht9F3wf2uk2Q0iuMsXHUwMDFkXHUwMDBlXHUwMDFhXHUwMDA21VO7auPTXHUwMDE2mGlcYnN0I3xcdTAwMGU24DVjzWMtXHQ+icJXXHUwMDE55VWbZ11VXcl9cSmheCU4QVx1MDAxZT92b0a7XHUwMDE57X+MXHUwMDA2jFGp4Ww0e/thICzlzNj8lnKyXHUwMDFk2lDYPl5ZbOGP03ubusWCa/Zw7Fx1MDAxY39cdTAwMDCLqmbvIn0=<!-- payload-end -->
   <defs>
     <style>
       @font-face {

Some files were not shown because too many files changed in this diff