Ryan Di 4 hete
szülő
commit
b562121a98
49 módosított fájl, 1487 hozzáadás és 602 törlés
  1. 24 32
      excalidraw-app/components/DebugCanvas.tsx
  2. 1 0
      packages/common/src/constants.ts
  3. 7 4
      packages/element/src/align.ts
  4. 53 38
      packages/element/src/delta.ts
  5. 9 2
      packages/element/src/distribute.ts
  6. 32 2
      packages/element/src/embeddable.ts
  7. 77 0
      packages/element/src/groups.ts
  8. 35 28
      packages/element/src/linearElementEditor.ts
  9. 5 0
      packages/element/src/renderElement.ts
  10. 15 7
      packages/element/src/store.ts
  11. 1 1
      packages/element/src/transformHandles.ts
  12. 420 0
      packages/element/tests/align.test.tsx
  13. 2 2
      packages/element/tests/binding.test.tsx
  14. 3 0
      packages/element/tests/delta.test.tsx
  15. 128 0
      packages/element/tests/distribute.test.tsx
  16. 153 0
      packages/element/tests/embeddable.test.ts
  17. 36 26
      packages/element/tests/linearElementEditor.test.tsx
  18. 13 2
      packages/excalidraw/actions/actionAlign.tsx
  19. 19 12
      packages/excalidraw/actions/actionDeleteSelected.tsx
  20. 8 1
      packages/excalidraw/actions/actionDistribute.tsx
  21. 1 1
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  22. 9 9
      packages/excalidraw/actions/actionFinalize.tsx
  23. 18 8
      packages/excalidraw/actions/actionLinearEditor.tsx
  24. 1 1
      packages/excalidraw/actions/actionSelectAll.ts
  25. 0 2
      packages/excalidraw/appState.ts
  26. 1 13
      packages/excalidraw/components/Actions.tsx
  27. 152 126
      packages/excalidraw/components/App.tsx
  28. 1 0
      packages/excalidraw/components/CommandPalette/CommandPalette.scss
  29. 3 1
      packages/excalidraw/components/CommandPalette/CommandPalette.tsx
  30. 18 0
      packages/excalidraw/components/Ellipsify.tsx
  31. 3 3
      packages/excalidraw/components/HintViewer.tsx
  32. 1 0
      packages/excalidraw/components/InlineIcon.tsx
  33. 0 1
      packages/excalidraw/components/canvases/InteractiveCanvas.tsx
  34. 7 20
      packages/excalidraw/components/canvases/StaticCanvas.tsx
  35. 3 0
      packages/excalidraw/components/dropdownMenu/DropdownMenu.scss
  36. 3 1
      packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx
  37. 1 20
      packages/excalidraw/components/footer/Footer.tsx
  38. 1 0
      packages/excalidraw/index.tsx
  39. 16 9
      packages/excalidraw/renderer/interactiveScene.ts
  40. 9 1
      packages/excalidraw/renderer/renderNewElementScene.ts
  41. 0 17
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  42. 65 13
      packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap
  43. 39 74
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  44. 70 96
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  45. 20 20
      packages/excalidraw/tests/history.test.tsx
  46. 1 1
      packages/excalidraw/tests/regressionTests.test.tsx
  47. 1 5
      packages/excalidraw/types.ts
  48. 2 2
      packages/excalidraw/wysiwyg/textWysiwyg.test.tsx
  49. 0 1
      packages/utils/tests/__snapshots__/export.test.ts.snap

+ 24 - 32
excalidraw-app/components/DebugCanvas.tsx

@@ -9,7 +9,7 @@ import {
 } from "@excalidraw/excalidraw/renderer/helpers";
 import { type AppState } from "@excalidraw/excalidraw/types";
 import { throttleRAF } from "@excalidraw/common";
-import { useCallback, useImperativeHandle, useRef } from "react";
+import { useCallback } from "react";
 
 import {
   isLineSegment,
@@ -18,6 +18,8 @@ import {
 } from "@excalidraw/math";
 import { isCurve } from "@excalidraw/math/curve";
 
+import React from "react";
+
 import type { Curve } from "@excalidraw/math";
 
 import type { DebugElement } from "@excalidraw/utils/visualdebug";
@@ -113,10 +115,6 @@ const _debugRenderer = (
     scale,
   );
 
-  if (appState.height !== canvas.height || appState.width !== canvas.width) {
-    refresh();
-  }
-
   const context = bootstrapCanvas({
     canvas,
     scale,
@@ -314,35 +312,29 @@ export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
 interface DebugCanvasProps {
   appState: AppState;
   scale: number;
-  ref?: React.Ref<HTMLCanvasElement>;
 }
 
-const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
-  const { width, height } = appState;
+const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
+  ({ appState, scale }, ref) => {
+    const { width, height } = appState;
 
-  const canvasRef = useRef<HTMLCanvasElement>(null);
-  useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
-    ref,
-    () => canvasRef.current,
-    [canvasRef],
-  );
-
-  return (
-    <canvas
-      style={{
-        width,
-        height,
-        position: "absolute",
-        zIndex: 2,
-        pointerEvents: "none",
-      }}
-      width={width * scale}
-      height={height * scale}
-      ref={canvasRef}
-    >
-      Debug Canvas
-    </canvas>
-  );
-};
+    return (
+      <canvas
+        style={{
+          width,
+          height,
+          position: "absolute",
+          zIndex: 2,
+          pointerEvents: "none",
+        }}
+        width={width * scale}
+        height={height * scale}
+        ref={ref}
+      >
+        Debug Canvas
+      </canvas>
+    );
+  },
+);
 
 export default DebugCanvas;

+ 1 - 0
packages/common/src/constants.ts

@@ -45,6 +45,7 @@ export const APP_NAME = "Excalidraw";
 // (happens a lot with fast clicks with the text tool)
 export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
 export const DRAGGING_THRESHOLD = 10; // px
+export const MINIMUM_ARROW_SIZE = 20; // px
 export const LINE_CONFIRM_THRESHOLD = 8; // px
 export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
 export const ELEMENT_TRANSLATE_AMOUNT = 1;

+ 7 - 4
packages/element/src/align.ts

@@ -1,6 +1,8 @@
+import type { AppState } from "@excalidraw/excalidraw/types";
+
 import { updateBoundElements } from "./binding";
 import { getCommonBoundingBox } from "./bounds";
-import { getMaximumGroups } from "./groups";
+import { getSelectedElementsByGroup } from "./groups";
 
 import type { Scene } from "./Scene";
 
@@ -16,11 +18,12 @@ export const alignElements = (
   selectedElements: ExcalidrawElement[],
   alignment: Alignment,
   scene: Scene,
+  appState: Readonly<AppState>,
 ): ExcalidrawElement[] => {
-  const elementsMap = scene.getNonDeletedElementsMap();
-  const groups: ExcalidrawElement[][] = getMaximumGroups(
+  const groups: ExcalidrawElement[][] = getSelectedElementsByGroup(
     selectedElements,
-    elementsMap,
+    scene.getNonDeletedElementsMap(),
+    appState,
   );
   const selectionBoundingBox = getCommonBoundingBox(selectedElements);
 

+ 53 - 38
packages/element/src/delta.ts

@@ -2,6 +2,7 @@ import {
   arrayToMap,
   arrayToObject,
   assertNever,
+  invariant,
   isDevEnv,
   isShallowEqual,
   isTestEnv,
@@ -548,7 +549,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
         selectedElementIds: addedSelectedElementIds = {},
         selectedGroupIds: addedSelectedGroupIds = {},
         selectedLinearElementId,
-        editingLinearElementId,
+        selectedLinearElementIsEditing,
         ...directlyApplicablePartial
       } = this.delta.inserted;
 
@@ -564,39 +565,46 @@ export class AppStateDelta implements DeltaContainer<AppState> {
         removedSelectedGroupIds,
       );
 
-      const selectedLinearElement =
-        selectedLinearElementId && nextElements.has(selectedLinearElementId)
-          ? new LinearElementEditor(
-              nextElements.get(
-                selectedLinearElementId,
-              ) as NonDeleted<ExcalidrawLinearElement>,
-              nextElements,
-            )
-          : null;
-
-      const editingLinearElement =
-        editingLinearElementId && nextElements.has(editingLinearElementId)
-          ? new LinearElementEditor(
-              nextElements.get(
-                editingLinearElementId,
-              ) as NonDeleted<ExcalidrawLinearElement>,
-              nextElements,
-            )
-          : null;
+      let selectedLinearElement = appState.selectedLinearElement;
+
+      if (selectedLinearElementId === null) {
+        // Unselect linear element (visible change)
+        selectedLinearElement = null;
+      } else if (
+        selectedLinearElementId &&
+        nextElements.has(selectedLinearElementId)
+      ) {
+        selectedLinearElement = new LinearElementEditor(
+          nextElements.get(
+            selectedLinearElementId,
+          ) as NonDeleted<ExcalidrawLinearElement>,
+          nextElements,
+          selectedLinearElementIsEditing === true, // Can be unknown which is defaulted to false
+        );
+      }
+
+      if (
+        // Value being 'null' is equivaluent to unknown in this case because it only gets set
+        // to null when 'selectedLinearElementId' is set to null
+        selectedLinearElementIsEditing != null
+      ) {
+        invariant(
+          selectedLinearElement,
+          `selectedLinearElement is null when selectedLinearElementIsEditing is set to ${selectedLinearElementIsEditing}`,
+        );
+
+        selectedLinearElement = {
+          ...selectedLinearElement,
+          isEditing: selectedLinearElementIsEditing,
+        };
+      }
 
       const nextAppState = {
         ...appState,
         ...directlyApplicablePartial,
         selectedElementIds: mergedSelectedElementIds,
         selectedGroupIds: mergedSelectedGroupIds,
-        selectedLinearElement:
-          typeof selectedLinearElementId !== "undefined"
-            ? selectedLinearElement // element was either inserted or deleted
-            : appState.selectedLinearElement, // otherwise assign what we had before
-        editingLinearElement:
-          typeof editingLinearElementId !== "undefined"
-            ? editingLinearElement // element was either inserted or deleted
-            : appState.editingLinearElement, // otherwise assign what we had before
+        selectedLinearElement,
       };
 
       const constainsVisibleChanges = this.filterInvisibleChanges(
@@ -725,8 +733,7 @@ export class AppStateDelta implements DeltaContainer<AppState> {
             }
 
             break;
-          case "selectedLinearElementId":
-          case "editingLinearElementId":
+          case "selectedLinearElementId": {
             const appStateKey = AppStateDelta.convertToAppStateKey(key);
             const linearElement = nextAppState[appStateKey];
 
@@ -746,6 +753,19 @@ export class AppStateDelta implements DeltaContainer<AppState> {
             }
 
             break;
+          }
+          case "selectedLinearElementIsEditing": {
+            // Changes in editing state are always visible
+            const prevIsEditing =
+              prevAppState.selectedLinearElement?.isEditing ?? false;
+            const nextIsEditing =
+              nextAppState.selectedLinearElement?.isEditing ?? false;
+
+            if (prevIsEditing !== nextIsEditing) {
+              visibleDifferenceFlag.value = true;
+            }
+            break;
+          }
           case "lockedMultiSelections": {
             const prevLockedUnits = prevAppState[key] || {};
             const nextLockedUnits = nextAppState[key] || {};
@@ -779,16 +799,11 @@ export class AppStateDelta implements DeltaContainer<AppState> {
   }
 
   private static convertToAppStateKey(
-    key: keyof Pick<
-      ObservedElementsAppState,
-      "selectedLinearElementId" | "editingLinearElementId"
-    >,
-  ): keyof Pick<AppState, "selectedLinearElement" | "editingLinearElement"> {
+    key: keyof Pick<ObservedElementsAppState, "selectedLinearElementId">,
+  ): keyof Pick<AppState, "selectedLinearElement"> {
     switch (key) {
       case "selectedLinearElementId":
         return "selectedLinearElement";
-      case "editingLinearElementId":
-        return "editingLinearElement";
     }
   }
 
@@ -856,8 +871,8 @@ export class AppStateDelta implements DeltaContainer<AppState> {
       editingGroupId,
       selectedGroupIds,
       selectedElementIds,
-      editingLinearElementId,
       selectedLinearElementId,
+      selectedLinearElementIsEditing,
       croppingElementId,
       lockedMultiSelections,
       activeLockedId,

+ 9 - 2
packages/element/src/distribute.ts

@@ -1,7 +1,9 @@
+import type { AppState } from "@excalidraw/excalidraw/types";
+
 import { getCommonBoundingBox } from "./bounds";
 import { newElementWith } from "./mutateElement";
 
-import { getMaximumGroups } from "./groups";
+import { getSelectedElementsByGroup } from "./groups";
 
 import type { ElementsMap, ExcalidrawElement } from "./types";
 
@@ -14,6 +16,7 @@ export const distributeElements = (
   selectedElements: ExcalidrawElement[],
   elementsMap: ElementsMap,
   distribution: Distribution,
+  appState: Readonly<AppState>,
 ): ExcalidrawElement[] => {
   const [start, mid, end, extent] =
     distribution.axis === "x"
@@ -21,7 +24,11 @@ export const distributeElements = (
       : (["minY", "midY", "maxY", "height"] as const);
 
   const bounds = getCommonBoundingBox(selectedElements);
-  const groups = getMaximumGroups(selectedElements, elementsMap)
+  const groups = getSelectedElementsByGroup(
+    selectedElements,
+    elementsMap,
+    appState,
+  )
     .map((group) => [group, getCommonBoundingBox(group)] as const)
     .sort((a, b) => a[1][mid] - b[1][mid]);
 

+ 32 - 2
packages/element/src/embeddable.ts

@@ -23,7 +23,7 @@ type IframeDataWithSandbox = MarkRequired<IframeData, "sandbox">;
 const embeddedLinkCache = new Map<string, IframeDataWithSandbox>();
 
 const RE_YOUTUBE =
-  /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
+  /^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)/;
 
 const RE_VIMEO =
   /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
@@ -56,6 +56,35 @@ const RE_REDDIT =
 const RE_REDDIT_EMBED =
   /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
 
+const parseYouTubeTimestamp = (url: string): number => {
+  let timeParam: string | null | undefined;
+
+  try {
+    const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
+    timeParam =
+      urlObj.searchParams.get("t") || urlObj.searchParams.get("start");
+  } catch (error) {
+    const timeMatch = url.match(/[?&#](?:t|start)=([^&#\s]+)/);
+    timeParam = timeMatch?.[1];
+  }
+
+  if (!timeParam) {
+    return 0;
+  }
+
+  if (/^\d+$/.test(timeParam)) {
+    return parseInt(timeParam, 10);
+  }
+
+  const timeMatch = timeParam.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/);
+  if (!timeMatch) {
+    return 0;
+  }
+
+  const [, hours = "0", minutes = "0", seconds = "0"] = timeMatch;
+  return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
+};
+
 const ALLOWED_DOMAINS = new Set([
   "youtube.com",
   "youtu.be",
@@ -113,7 +142,8 @@ export const getEmbedLink = (
   let aspectRatio = { w: 560, h: 840 };
   const ytLink = link.match(RE_YOUTUBE);
   if (ytLink?.[2]) {
-    const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
+    const startTime = parseYouTubeTimestamp(originalLink);
+    const time = startTime > 0 ? `&start=${startTime}` : ``;
     const isPortrait = link.includes("shorts");
     type = "video";
     switch (ytLink[1]) {

+ 77 - 0
packages/element/src/groups.ts

@@ -7,6 +7,8 @@ import type { Mutable } from "@excalidraw/common/utility-types";
 
 import { getBoundTextElement } from "./textElement";
 
+import { isBoundToContainer } from "./typeChecks";
+
 import { makeNextSelectedElementIds, getSelectedElements } from "./selection";
 
 import type {
@@ -402,3 +404,78 @@ export const getNewGroupIdsForDuplication = (
 
   return copy;
 };
+
+// given a list of selected elements, return the element grouped by their immediate group selected state
+// in the case if only one group is selected and all elements selected are within the group, it will respect group hierarchy in accordance to their nested grouping order
+export const getSelectedElementsByGroup = (
+  selectedElements: ExcalidrawElement[],
+  elementsMap: ElementsMap,
+  appState: Readonly<AppState>,
+): ExcalidrawElement[][] => {
+  const selectedGroupIds = getSelectedGroupIds(appState);
+  const unboundElements = selectedElements.filter(
+    (element) => !isBoundToContainer(element),
+  );
+  const groups: Map<string, ExcalidrawElement[]> = new Map();
+  const elements: Map<string, ExcalidrawElement[]> = new Map();
+
+  // helper function to add an element to the elements map
+  const addToElementsMap = (element: ExcalidrawElement) => {
+    // elements
+    const currentElementMembers = elements.get(element.id) || [];
+    const boundTextElement = getBoundTextElement(element, elementsMap);
+
+    if (boundTextElement) {
+      currentElementMembers.push(boundTextElement);
+    }
+    elements.set(element.id, [...currentElementMembers, element]);
+  };
+
+  // helper function to add an element to the groups map
+  const addToGroupsMap = (element: ExcalidrawElement, groupId: string) => {
+    // groups
+    const currentGroupMembers = groups.get(groupId) || [];
+    const boundTextElement = getBoundTextElement(element, elementsMap);
+
+    if (boundTextElement) {
+      currentGroupMembers.push(boundTextElement);
+    }
+    groups.set(groupId, [...currentGroupMembers, element]);
+  };
+
+  // helper function to handle the case where a single group is selected
+  // and all elements selected are within the group, it will respect group hierarchy in accordance to
+  // their nested grouping order
+  const handleSingleSelectedGroupCase = (
+    element: ExcalidrawElement,
+    selectedGroupId: GroupId,
+  ) => {
+    const indexOfSelectedGroupId = element.groupIds.indexOf(selectedGroupId, 0);
+    const nestedGroupCount = element.groupIds.slice(
+      0,
+      indexOfSelectedGroupId,
+    ).length;
+    return nestedGroupCount > 0
+      ? addToGroupsMap(element, element.groupIds[indexOfSelectedGroupId - 1])
+      : addToElementsMap(element);
+  };
+
+  const isAllInSameGroup = selectedElements.every((element) =>
+    isSelectedViaGroup(appState, element),
+  );
+
+  unboundElements.forEach((element) => {
+    const selectedGroupId = getSelectedGroupIdForElement(
+      element,
+      appState.selectedGroupIds,
+    );
+    if (!selectedGroupId) {
+      addToElementsMap(element);
+    } else if (selectedGroupIds.length === 1 && isAllInSameGroup) {
+      handleSingleSelectedGroupCase(element, selectedGroupId);
+    } else {
+      addToGroupsMap(element, selectedGroupId);
+    }
+  });
+  return Array.from(groups.values()).concat(Array.from(elements.values()));
+};

+ 35 - 28
packages/element/src/linearElementEditor.ts

@@ -149,10 +149,12 @@ export class LinearElementEditor {
   public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
   public readonly elbowed: boolean;
   public readonly customLineAngle: number | null;
+  public readonly isEditing: boolean;
 
   constructor(
     element: NonDeleted<ExcalidrawLinearElement>,
     elementsMap: ElementsMap,
+    isEditing: boolean = false,
   ) {
     this.elementId = element.id as string & {
       _brand: "excalidrawLinearElementId";
@@ -187,6 +189,7 @@ export class LinearElementEditor {
     this.segmentMidPointHoveredCoords = null;
     this.elbowed = isElbowArrow(element) && element.elbowed;
     this.customLineAngle = null;
+    this.isEditing = isEditing;
   }
 
   // ---------------------------------------------------------------------------
@@ -194,6 +197,7 @@ export class LinearElementEditor {
   // ---------------------------------------------------------------------------
 
   static POINT_HANDLE_SIZE = 10;
+
   /**
    * @param id the `elementId` from the instance of this class (so that we can
    *  statically guarantee this method returns an ExcalidrawLinearElement)
@@ -215,11 +219,14 @@ export class LinearElementEditor {
     setState: React.Component<any, AppState>["setState"],
     elementsMap: NonDeletedSceneElementsMap,
   ) {
-    if (!appState.editingLinearElement || !appState.selectionElement) {
+    if (
+      !appState.selectedLinearElement?.isEditing ||
+      !appState.selectionElement
+    ) {
       return false;
     }
-    const { editingLinearElement } = appState;
-    const { selectedPointsIndices, elementId } = editingLinearElement;
+    const { selectedLinearElement } = appState;
+    const { selectedPointsIndices, elementId } = selectedLinearElement;
 
     const element = LinearElementEditor.getElement(elementId, elementsMap);
     if (!element) {
@@ -260,8 +267,8 @@ export class LinearElementEditor {
       });
 
     setState({
-      editingLinearElement: {
-        ...editingLinearElement,
+      selectedLinearElement: {
+        ...selectedLinearElement,
         selectedPointsIndices: nextSelectedPoints.length
           ? nextSelectedPoints
           : null,
@@ -479,9 +486,6 @@ export class LinearElementEditor {
 
       return {
         ...app.state,
-        editingLinearElement: app.state.editingLinearElement
-          ? newLinearElementEditor
-          : null,
         selectedLinearElement: newLinearElementEditor,
         suggestedBindings,
       };
@@ -618,7 +622,7 @@ export class LinearElementEditor {
     // Since its not needed outside editor unless 2 pointer lines or bound text
     if (
       !isElbowArrow(element) &&
-      !appState.editingLinearElement &&
+      !appState.selectedLinearElement?.isEditing &&
       element.points.length > 2 &&
       !boundText
     ) {
@@ -684,7 +688,7 @@ export class LinearElementEditor {
     );
     if (
       points.length >= 3 &&
-      !appState.editingLinearElement &&
+      !appState.selectedLinearElement?.isEditing &&
       !isElbowArrow(element)
     ) {
       return null;
@@ -881,7 +885,7 @@ export class LinearElementEditor {
         segmentMidpoint,
         elementsMap,
       );
-    } else if (event.altKey && appState.editingLinearElement) {
+    } else if (event.altKey && appState.selectedLinearElement?.isEditing) {
       if (linearElementEditor.lastUncommittedPoint == null) {
         scene.mutateElement(element, {
           points: [
@@ -1023,14 +1027,14 @@ export class LinearElementEditor {
     app: AppClassProperties,
   ): LinearElementEditor | null {
     const appState = app.state;
-    if (!appState.editingLinearElement) {
+    if (!appState.selectedLinearElement?.isEditing) {
       return null;
     }
-    const { elementId, lastUncommittedPoint } = appState.editingLinearElement;
+    const { elementId, lastUncommittedPoint } = appState.selectedLinearElement;
     const elementsMap = app.scene.getNonDeletedElementsMap();
     const element = LinearElementEditor.getElement(elementId, elementsMap);
     if (!element) {
-      return appState.editingLinearElement;
+      return appState.selectedLinearElement;
     }
 
     const { points } = element;
@@ -1040,10 +1044,12 @@ export class LinearElementEditor {
       if (lastPoint === lastUncommittedPoint) {
         LinearElementEditor.deletePoints(element, app, [points.length - 1]);
       }
-      return {
-        ...appState.editingLinearElement,
-        lastUncommittedPoint: null,
-      };
+      return appState.selectedLinearElement?.lastUncommittedPoint
+        ? {
+            ...appState.selectedLinearElement,
+            lastUncommittedPoint: null,
+          }
+        : appState.selectedLinearElement;
     }
 
     let newPoint: LocalPoint;
@@ -1067,8 +1073,8 @@ export class LinearElementEditor {
       newPoint = LinearElementEditor.createPointAt(
         element,
         elementsMap,
-        scenePointerX - appState.editingLinearElement.pointerOffset.x,
-        scenePointerY - appState.editingLinearElement.pointerOffset.y,
+        scenePointerX - appState.selectedLinearElement.pointerOffset.x,
+        scenePointerY - appState.selectedLinearElement.pointerOffset.y,
         event[KEYS.CTRL_OR_CMD] || isElbowArrow(element)
           ? null
           : app.getEffectiveGridSize(),
@@ -1092,7 +1098,7 @@ export class LinearElementEditor {
       LinearElementEditor.addPoints(element, app.scene, [newPoint]);
     }
     return {
-      ...appState.editingLinearElement,
+      ...appState.selectedLinearElement,
       lastUncommittedPoint: element.points[element.points.length - 1],
     };
   }
@@ -1251,12 +1257,12 @@ export class LinearElementEditor {
   // ---------------------------------------------------------------------------
   static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
     invariant(
-      appState.editingLinearElement,
+      appState.selectedLinearElement?.isEditing,
       "Not currently editing a linear element",
     );
 
     const elementsMap = scene.getNonDeletedElementsMap();
-    const { selectedPointsIndices, elementId } = appState.editingLinearElement;
+    const { selectedPointsIndices, elementId } = appState.selectedLinearElement;
     const element = LinearElementEditor.getElement(elementId, elementsMap);
 
     invariant(
@@ -1318,8 +1324,8 @@ export class LinearElementEditor {
 
     return {
       ...appState,
-      editingLinearElement: {
-        ...appState.editingLinearElement,
+      selectedLinearElement: {
+        ...appState.selectedLinearElement,
         selectedPointsIndices: nextSelectedIndices,
       },
     };
@@ -1331,8 +1337,9 @@ export class LinearElementEditor {
     pointIndices: readonly number[],
   ) {
     const isUncommittedPoint =
-      app.state.editingLinearElement?.lastUncommittedPoint ===
-      element.points[element.points.length - 1];
+      app.state.selectedLinearElement?.isEditing &&
+      app.state.selectedLinearElement?.lastUncommittedPoint ===
+        element.points[element.points.length - 1];
 
     const nextPoints = element.points.filter((_, idx) => {
       return !pointIndices.includes(idx);
@@ -1505,7 +1512,7 @@ export class LinearElementEditor {
       pointFrom(pointerCoords.x, pointerCoords.y),
     );
     if (
-      !appState.editingLinearElement &&
+      !appState.selectedLinearElement?.isEditing &&
       dist < DRAGGING_THRESHOLD / appState.zoom.value
     ) {
       return false;

+ 5 - 0
packages/element/src/renderElement.ts

@@ -106,6 +106,11 @@ const getCanvasPadding = (element: ExcalidrawElement) => {
       return element.strokeWidth * 12;
     case "text":
       return element.fontSize / 2;
+    case "arrow":
+      if (element.endArrowhead || element.endArrowhead) {
+        return 40;
+      }
+      return 20;
     default:
       return 20;
   }

+ 15 - 7
packages/element/src/store.ts

@@ -27,6 +27,8 @@ import {
   isImageElement,
 } from "./index";
 
+import type { ApplyToOptions } from "./delta";
+
 import type {
   ExcalidrawElement,
   OrderedExcalidrawElement,
@@ -570,9 +572,15 @@ export class StoreDelta {
     delta: StoreDelta,
     elements: SceneElementsMap,
     appState: AppState,
+    options: ApplyToOptions = {
+      excludedProperties: new Set(),
+    },
   ): [SceneElementsMap, AppState, boolean] {
-    const [nextElements, elementsContainVisibleChange] =
-      delta.elements.applyTo(elements);
+    const [nextElements, elementsContainVisibleChange] = delta.elements.applyTo(
+      elements,
+      StoreSnapshot.empty().elements,
+      options,
+    );
 
     const [nextAppState, appStateContainsVisibleChange] =
       delta.appState.applyTo(appState, nextElements);
@@ -970,8 +978,8 @@ const getDefaultObservedAppState = (): ObservedAppState => {
     viewBackgroundColor: COLOR_PALETTE.white,
     selectedElementIds: {},
     selectedGroupIds: {},
-    editingLinearElementId: null,
     selectedLinearElementId: null,
+    selectedLinearElementIsEditing: null,
     croppingElementId: null,
     activeLockedId: null,
     lockedMultiSelections: {},
@@ -990,14 +998,14 @@ export const getObservedAppState = (
     croppingElementId: appState.croppingElementId,
     activeLockedId: appState.activeLockedId,
     lockedMultiSelections: appState.lockedMultiSelections,
-    editingLinearElementId:
-      (appState as AppState).editingLinearElement?.elementId ?? // prefer app state, as it's likely newer
-      (appState as ObservedAppState).editingLinearElementId ?? // fallback to observed app state, as it's likely older coming from a previous snapshot
-      null,
     selectedLinearElementId:
       (appState as AppState).selectedLinearElement?.elementId ??
       (appState as ObservedAppState).selectedLinearElementId ??
       null,
+    selectedLinearElementIsEditing:
+      (appState as AppState).selectedLinearElement?.isEditing ??
+      (appState as ObservedAppState).selectedLinearElementIsEditing ??
+      null,
   };
 
   Reflect.defineProperty(observedAppState, hiddenObservedAppStateProp, {

+ 1 - 1
packages/element/src/transformHandles.ts

@@ -330,7 +330,7 @@ export const shouldShowBoundingBox = (
   elements: readonly NonDeletedExcalidrawElement[],
   appState: InteractiveCanvasAppState,
 ) => {
-  if (appState.editingLinearElement) {
+  if (appState.selectedLinearElement?.isEditing) {
     return false;
   }
   if (elements.length > 1) {

+ 420 - 0
packages/element/tests/align.test.tsx

@@ -589,4 +589,424 @@ describe("aligning", () => {
     expect(API.getSelectedElements()[2].x).toEqual(250);
     expect(API.getSelectedElements()[3].x).toEqual(150);
   });
+
+  const createGroupAndSelectInEditGroupMode = () => {
+    UI.clickTool("rectangle");
+    mouse.down();
+    mouse.up(100, 100);
+
+    UI.clickTool("rectangle");
+    mouse.down(0, 0);
+    mouse.up(100, 100);
+
+    // select the first element.
+    // The second rectangle is already reselected because it was the last element created
+    mouse.reset();
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
+      mouse.click();
+    });
+
+    API.executeAction(actionGroup);
+    mouse.reset();
+    mouse.moveTo(10, 0);
+    mouse.doubleClick();
+
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.click();
+      mouse.moveTo(100, 100);
+      mouse.click();
+    });
+  };
+
+  it("aligns elements within a group while in group edit mode correctly to the top", () => {
+    createGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+
+    API.executeAction(actionAlignTop);
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(0);
+  });
+  it("aligns elements within a group while in group edit mode correctly to the bottom", () => {
+    createGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+
+    API.executeAction(actionAlignBottom);
+
+    expect(API.getSelectedElements()[0].y).toEqual(100);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+  });
+  it("aligns elements within a group while in group edit mode correctly to the left", () => {
+    createGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+
+    API.executeAction(actionAlignLeft);
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(0);
+  });
+  it("aligns elements within a group while in group edit mode correctly to the right", () => {
+    createGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+
+    API.executeAction(actionAlignRight);
+
+    expect(API.getSelectedElements()[0].x).toEqual(100);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+  });
+  it("aligns elements within a group while in group edit mode correctly to the vertical center", () => {
+    createGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+
+    API.executeAction(actionAlignVerticallyCentered);
+
+    expect(API.getSelectedElements()[0].y).toEqual(50);
+    expect(API.getSelectedElements()[1].y).toEqual(50);
+  });
+  it("aligns elements within a group while in group edit mode correctly to the horizontal center", () => {
+    createGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+
+    API.executeAction(actionAlignHorizontallyCentered);
+
+    expect(API.getSelectedElements()[0].x).toEqual(50);
+    expect(API.getSelectedElements()[1].x).toEqual(50);
+  });
+
+  const createNestedGroupAndSelectInEditGroupMode = () => {
+    UI.clickTool("rectangle");
+    mouse.down();
+    mouse.up(100, 100);
+
+    UI.clickTool("rectangle");
+    mouse.down(0, 0);
+    mouse.up(100, 100);
+
+    // Select the first element.
+    // The second rectangle is already reselected because it was the last element created
+    mouse.reset();
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
+      mouse.click();
+    });
+
+    API.executeAction(actionGroup);
+
+    mouse.reset();
+    mouse.moveTo(200, 200);
+    // create third element
+    UI.clickTool("rectangle");
+    mouse.down(0, 0);
+    mouse.up(100, 100);
+
+    // third element is already selected, select the initial group and group together
+    mouse.reset();
+
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
+      mouse.click();
+    });
+
+    API.executeAction(actionGroup);
+
+    // double click to enter edit mode
+    mouse.doubleClick();
+
+    // select nested group and other element within the group
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(200, 200);
+      mouse.click();
+    });
+  };
+
+  it("aligns element and nested group while in group edit mode correctly to the top", () => {
+    createNestedGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+
+    API.executeAction(actionAlignTop);
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(0);
+  });
+  it("aligns element and nested group while in group edit mode correctly to the bottom", () => {
+    createNestedGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+
+    API.executeAction(actionAlignBottom);
+
+    expect(API.getSelectedElements()[0].y).toEqual(100);
+    expect(API.getSelectedElements()[1].y).toEqual(200);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+  });
+  it("aligns element and nested group while in group edit mode correctly to the left", () => {
+    createNestedGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+
+    API.executeAction(actionAlignLeft);
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(0);
+  });
+  it("aligns element and nested group while in group edit mode correctly to the right", () => {
+    createNestedGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+
+    API.executeAction(actionAlignRight);
+
+    expect(API.getSelectedElements()[0].x).toEqual(100);
+    expect(API.getSelectedElements()[1].x).toEqual(200);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+  });
+  it("aligns element and nested group while in group edit mode correctly to the vertical center", () => {
+    createNestedGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+
+    API.executeAction(actionAlignVerticallyCentered);
+
+    expect(API.getSelectedElements()[0].y).toEqual(50);
+    expect(API.getSelectedElements()[1].y).toEqual(150);
+    expect(API.getSelectedElements()[2].y).toEqual(100);
+  });
+  it("aligns elements and nested group within a group while in group edit mode correctly to the horizontal center", () => {
+    createNestedGroupAndSelectInEditGroupMode();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+
+    API.executeAction(actionAlignHorizontallyCentered);
+
+    expect(API.getSelectedElements()[0].x).toEqual(50);
+    expect(API.getSelectedElements()[1].x).toEqual(150);
+    expect(API.getSelectedElements()[2].x).toEqual(100);
+  });
+
+  const createAndSelectSingleGroup = () => {
+    UI.clickTool("rectangle");
+    mouse.down();
+    mouse.up(100, 100);
+
+    UI.clickTool("rectangle");
+    mouse.down(0, 0);
+    mouse.up(100, 100);
+
+    // Select the first element.
+    // The second rectangle is already reselected because it was the last element created
+    mouse.reset();
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
+      mouse.click();
+    });
+
+    API.executeAction(actionGroup);
+  };
+
+  it("aligns elements within a single-selected group correctly to the top", () => {
+    createAndSelectSingleGroup();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+
+    API.executeAction(actionAlignTop);
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(0);
+  });
+  it("aligns elements within a single-selected group correctly to the bottom", () => {
+    createAndSelectSingleGroup();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+
+    API.executeAction(actionAlignBottom);
+
+    expect(API.getSelectedElements()[0].y).toEqual(100);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+  });
+  it("aligns elements within a single-selected group correctly to the left", () => {
+    createAndSelectSingleGroup();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+
+    API.executeAction(actionAlignLeft);
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(0);
+  });
+  it("aligns elements within a single-selected group correctly to the right", () => {
+    createAndSelectSingleGroup();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+
+    API.executeAction(actionAlignRight);
+
+    expect(API.getSelectedElements()[0].x).toEqual(100);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+  });
+  it("aligns elements within a single-selected group correctly to the vertical center", () => {
+    createAndSelectSingleGroup();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+
+    API.executeAction(actionAlignVerticallyCentered);
+
+    expect(API.getSelectedElements()[0].y).toEqual(50);
+    expect(API.getSelectedElements()[1].y).toEqual(50);
+  });
+  it("aligns elements within a single-selected group correctly to the horizontal center", () => {
+    createAndSelectSingleGroup();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+
+    API.executeAction(actionAlignHorizontallyCentered);
+
+    expect(API.getSelectedElements()[0].x).toEqual(50);
+    expect(API.getSelectedElements()[1].x).toEqual(50);
+  });
+
+  const createAndSelectSingleGroupWithNestedGroup = () => {
+    UI.clickTool("rectangle");
+    mouse.down();
+    mouse.up(100, 100);
+
+    UI.clickTool("rectangle");
+    mouse.down(0, 0);
+    mouse.up(100, 100);
+
+    // Select the first element.
+    // The second rectangle is already reselected because it was the last element created
+    mouse.reset();
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
+      mouse.click();
+    });
+
+    API.executeAction(actionGroup);
+
+    mouse.reset();
+    UI.clickTool("rectangle");
+    mouse.down(200, 200);
+    mouse.up(100, 100);
+
+    // Add group to current selection
+    mouse.restorePosition(10, 0);
+    Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.click();
+    });
+
+    // Create the nested group
+    API.executeAction(actionGroup);
+  };
+  it("aligns elements within a single-selected group containing a nested group correctly to the top", () => {
+    createAndSelectSingleGroupWithNestedGroup();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+
+    API.executeAction(actionAlignTop);
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(0);
+  });
+  it("aligns elements within a single-selected group containing a nested group correctly to the bottom", () => {
+    createAndSelectSingleGroupWithNestedGroup();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+
+    API.executeAction(actionAlignBottom);
+
+    expect(API.getSelectedElements()[0].y).toEqual(100);
+    expect(API.getSelectedElements()[1].y).toEqual(200);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+  });
+  it("aligns elements within a single-selected group containing a nested group correctly to the left", () => {
+    createAndSelectSingleGroupWithNestedGroup();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+
+    API.executeAction(actionAlignLeft);
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(0);
+  });
+  it("aligns elements within a single-selected group containing a nested group correctly to the right", () => {
+    createAndSelectSingleGroupWithNestedGroup();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+
+    API.executeAction(actionAlignRight);
+
+    expect(API.getSelectedElements()[0].x).toEqual(100);
+    expect(API.getSelectedElements()[1].x).toEqual(200);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+  });
+  it("aligns elements within a single-selected group containing a nested group correctly to the vertical center", () => {
+    createAndSelectSingleGroupWithNestedGroup();
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(100);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+
+    API.executeAction(actionAlignVerticallyCentered);
+
+    expect(API.getSelectedElements()[0].y).toEqual(50);
+    expect(API.getSelectedElements()[1].y).toEqual(150);
+    expect(API.getSelectedElements()[2].y).toEqual(100);
+  });
+  it("aligns elements within a single-selected group containing a nested group correctly to the horizontal center", () => {
+    createAndSelectSingleGroupWithNestedGroup();
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(100);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+
+    API.executeAction(actionAlignHorizontallyCentered);
+
+    expect(API.getSelectedElements()[0].x).toEqual(50);
+    expect(API.getSelectedElements()[1].x).toEqual(150);
+    expect(API.getSelectedElements()[2].x).toEqual(100);
+  });
 });

+ 2 - 2
packages/element/tests/binding.test.tsx

@@ -155,10 +155,10 @@ describe("element binding", () => {
       // NOTE this mouse down/up + await needs to be done in order to repro
       // the issue, due to https://github.com/excalidraw/excalidraw/blob/46bff3daceb602accf60c40a84610797260fca94/src/components/App.tsx#L740
       mouse.reset();
-      expect(h.state.editingLinearElement).not.toBe(null);
+      expect(h.state.selectedLinearElement?.isEditing).toBe(true);
       mouse.down(0, 0);
       await new Promise((r) => setTimeout(r, 100));
-      expect(h.state.editingLinearElement).toBe(null);
+      expect(h.state.selectedLinearElement?.isEditing).toBe(false);
       expect(API.getSelectedElement().type).toBe("rectangle");
       mouse.up();
       expect(API.getSelectedElement().type).toBe("rectangle");

+ 3 - 0
packages/element/tests/delta.test.tsx

@@ -16,6 +16,7 @@ describe("AppStateDelta", () => {
         editingGroupId: null,
         croppingElementId: null,
         editingLinearElementId: null,
+        selectedLinearElementIsEditing: null,
         lockedMultiSelections: {},
         activeLockedId: null,
       };
@@ -58,6 +59,7 @@ describe("AppStateDelta", () => {
         editingGroupId: null,
         croppingElementId: null,
         selectedLinearElementId: null,
+        selectedLinearElementIsEditing: null,
         editingLinearElementId: null,
         activeLockedId: null,
         lockedMultiSelections: {},
@@ -105,6 +107,7 @@ describe("AppStateDelta", () => {
         editingGroupId: null,
         croppingElementId: null,
         selectedLinearElementId: null,
+        selectedLinearElementIsEditing: null,
         editingLinearElementId: null,
         activeLockedId: null,
         lockedMultiSelections: {},

+ 128 - 0
packages/element/tests/distribute.test.tsx

@@ -0,0 +1,128 @@
+import {
+  distributeHorizontally,
+  distributeVertically,
+} from "@excalidraw/excalidraw/actions";
+import { defaultLang, setLanguage } from "@excalidraw/excalidraw/i18n";
+import { Excalidraw } from "@excalidraw/excalidraw";
+
+import { API } from "@excalidraw/excalidraw/tests/helpers/api";
+import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
+import {
+  act,
+  unmountComponent,
+  render,
+} from "@excalidraw/excalidraw/tests/test-utils";
+
+const mouse = new Pointer("mouse");
+
+// Scenario: three rectangles that will be distributed with gaps
+const createAndSelectThreeRectanglesWithGap = () => {
+  UI.clickTool("rectangle");
+  mouse.down();
+  mouse.up(100, 100);
+  mouse.reset();
+
+  UI.clickTool("rectangle");
+  mouse.down(10, 10);
+  mouse.up(100, 100);
+  mouse.reset();
+
+  UI.clickTool("rectangle");
+  mouse.down(300, 300);
+  mouse.up(100, 100);
+  mouse.reset();
+
+  // Last rectangle is selected by default
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click(0, 10);
+    mouse.click(10, 0);
+  });
+};
+
+// Scenario: three rectangles that will be distributed by their centers
+const createAndSelectThreeRectanglesWithoutGap = () => {
+  UI.clickTool("rectangle");
+  mouse.down();
+  mouse.up(100, 100);
+  mouse.reset();
+
+  UI.clickTool("rectangle");
+  mouse.down(10, 10);
+  mouse.up(200, 200);
+  mouse.reset();
+
+  UI.clickTool("rectangle");
+  mouse.down(200, 200);
+  mouse.up(100, 100);
+  mouse.reset();
+
+  // Last rectangle is selected by default
+  Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.click(0, 10);
+    mouse.click(10, 0);
+  });
+};
+
+describe("distributing", () => {
+  beforeEach(async () => {
+    unmountComponent();
+    mouse.reset();
+
+    await act(() => {
+      return setLanguage(defaultLang);
+    });
+    await render(<Excalidraw handleKeyboardGlobally={true} />);
+  });
+
+  it("should distribute selected elements horizontally", async () => {
+    createAndSelectThreeRectanglesWithGap();
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(10);
+    expect(API.getSelectedElements()[2].x).toEqual(300);
+
+    API.executeAction(distributeHorizontally);
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(150);
+    expect(API.getSelectedElements()[2].x).toEqual(300);
+  });
+
+  it("should distribute selected elements vertically", async () => {
+    createAndSelectThreeRectanglesWithGap();
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(10);
+    expect(API.getSelectedElements()[2].y).toEqual(300);
+
+    API.executeAction(distributeVertically);
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(150);
+    expect(API.getSelectedElements()[2].y).toEqual(300);
+  });
+
+  it("should distribute selected elements horizontally based on their centers", async () => {
+    createAndSelectThreeRectanglesWithoutGap();
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(10);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+
+    API.executeAction(distributeHorizontally);
+
+    expect(API.getSelectedElements()[0].x).toEqual(0);
+    expect(API.getSelectedElements()[1].x).toEqual(50);
+    expect(API.getSelectedElements()[2].x).toEqual(200);
+  });
+
+  it("should distribute selected elements vertically with based on their centers", async () => {
+    createAndSelectThreeRectanglesWithoutGap();
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(10);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+
+    API.executeAction(distributeVertically);
+
+    expect(API.getSelectedElements()[0].y).toEqual(0);
+    expect(API.getSelectedElements()[1].y).toEqual(50);
+    expect(API.getSelectedElements()[2].y).toEqual(200);
+  });
+});

+ 153 - 0
packages/element/tests/embeddable.test.ts

@@ -0,0 +1,153 @@
+import { getEmbedLink } from "../src/embeddable";
+
+describe("YouTube timestamp parsing", () => {
+  it("should parse YouTube URLs with timestamp in seconds", () => {
+    const testCases = [
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90",
+        expectedStart: 90,
+      },
+      {
+        url: "https://youtu.be/dQw4w9WgXcQ?t=120",
+        expectedStart: 120,
+      },
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&start=150",
+        expectedStart: 150,
+      },
+    ];
+
+    testCases.forEach(({ url, expectedStart }) => {
+      const result = getEmbedLink(url);
+      expect(result).toBeTruthy();
+      expect(result?.type).toBe("video");
+      if (result?.type === "video" || result?.type === "generic") {
+        expect(result.link).toContain(`start=${expectedStart}`);
+      }
+    });
+  });
+
+  it("should parse YouTube URLs with timestamp in time format", () => {
+    const testCases = [
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1m30s",
+        expectedStart: 90, // 1*60 + 30
+      },
+      {
+        url: "https://youtu.be/dQw4w9WgXcQ?t=2m45s",
+        expectedStart: 165, // 2*60 + 45
+      },
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=1h2m3s",
+        expectedStart: 3723, // 1*3600 + 2*60 + 3
+      },
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=45s",
+        expectedStart: 45,
+      },
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5m",
+        expectedStart: 300, // 5*60
+      },
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=2h",
+        expectedStart: 7200, // 2*3600
+      },
+    ];
+
+    testCases.forEach(({ url, expectedStart }) => {
+      const result = getEmbedLink(url);
+      expect(result).toBeTruthy();
+      expect(result?.type).toBe("video");
+      if (result?.type === "video" || result?.type === "generic") {
+        expect(result.link).toContain(`start=${expectedStart}`);
+      }
+    });
+  });
+
+  it("should handle YouTube URLs without timestamps", () => {
+    const testCases = [
+      "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+      "https://youtu.be/dQw4w9WgXcQ",
+      "https://www.youtube.com/embed/dQw4w9WgXcQ",
+    ];
+
+    testCases.forEach((url) => {
+      const result = getEmbedLink(url);
+      expect(result).toBeTruthy();
+      expect(result?.type).toBe("video");
+      if (result?.type === "video" || result?.type === "generic") {
+        expect(result.link).not.toContain("start=");
+      }
+    });
+  });
+
+  it("should handle YouTube shorts URLs with timestamps", () => {
+    const url = "https://www.youtube.com/shorts/dQw4w9WgXcQ?t=30";
+    const result = getEmbedLink(url);
+
+    expect(result).toBeTruthy();
+    expect(result?.type).toBe("video");
+    if (result?.type === "video" || result?.type === "generic") {
+      expect(result.link).toContain("start=30");
+    }
+    // Shorts should have portrait aspect ratio
+    expect(result?.intrinsicSize).toEqual({ w: 315, h: 560 });
+  });
+
+  it("should handle playlist URLs with timestamps", () => {
+    const url =
+      "https://www.youtube.com/playlist?list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ&t=60";
+    const result = getEmbedLink(url);
+
+    expect(result).toBeTruthy();
+    expect(result?.type).toBe("video");
+    if (result?.type === "video" || result?.type === "generic") {
+      expect(result.link).toContain("start=60");
+      expect(result.link).toContain("list=PLrAXtmRdnEQy1KbG5lbfgQ0-PKQY6FKYZ");
+    }
+  });
+
+  it("should handle malformed or edge case timestamps", () => {
+    const testCases = [
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=abc",
+        expectedStart: 0, // Invalid timestamp should default to 0
+      },
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=",
+        expectedStart: 0, // Empty timestamp should default to 0
+      },
+      {
+        url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=0",
+        expectedStart: 0, // Zero timestamp should be handled
+      },
+    ];
+
+    testCases.forEach(({ url, expectedStart }) => {
+      const result = getEmbedLink(url);
+      expect(result).toBeTruthy();
+      expect(result?.type).toBe("video");
+      if (result?.type === "video" || result?.type === "generic") {
+        if (expectedStart === 0) {
+          expect(result.link).not.toContain("start=");
+        } else {
+          expect(result.link).toContain(`start=${expectedStart}`);
+        }
+      }
+    });
+  });
+
+  it("should preserve other URL parameters", () => {
+    const url =
+      "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=90&feature=youtu.be&list=PLtest";
+    const result = getEmbedLink(url);
+
+    expect(result).toBeTruthy();
+    expect(result?.type).toBe("video");
+    if (result?.type === "video" || result?.type === "generic") {
+      expect(result.link).toContain("start=90");
+      expect(result.link).toContain("enablejsapi=1");
+    }
+  });
+});

+ 36 - 26
packages/element/tests/linearElementEditor.test.tsx

@@ -136,7 +136,8 @@ describe("Test Linear Elements", () => {
     Keyboard.withModifierKeys({ ctrl: true }, () => {
       Keyboard.keyPress(KEYS.ENTER);
     });
-    expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(line.id);
   };
 
   const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
@@ -253,75 +254,82 @@ describe("Test Linear Elements", () => {
     });
     fireEvent.click(queryByText(contextMenu as HTMLElement, "Edit line")!);
 
-    expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
   });
 
   it("should enter line editor via enter (line)", () => {
     createTwoPointerLinearElement("line");
-    expect(h.state.editingLinearElement?.elementId).toBeUndefined();
+    expect(h.state.selectedLinearElement?.isEditing).toBe(false);
 
     mouse.clickAt(midpoint[0], midpoint[1]);
     Keyboard.keyPress(KEYS.ENTER);
-    expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
   });
 
   // ctrl+enter alias (to align with arrows)
   it("should enter line editor via ctrl+enter (line)", () => {
     createTwoPointerLinearElement("line");
-    expect(h.state.editingLinearElement?.elementId).toBeUndefined();
+    expect(h.state.selectedLinearElement?.isEditing).toBe(false);
 
     mouse.clickAt(midpoint[0], midpoint[1]);
     Keyboard.withModifierKeys({ ctrl: true }, () => {
       Keyboard.keyPress(KEYS.ENTER);
     });
-    expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
   });
 
   it("should enter line editor via ctrl+enter (arrow)", () => {
     createTwoPointerLinearElement("arrow");
-    expect(h.state.editingLinearElement?.elementId).toBeUndefined();
+    expect(h.state.selectedLinearElement?.isEditing).toBe(false);
 
     mouse.clickAt(midpoint[0], midpoint[1]);
     Keyboard.withModifierKeys({ ctrl: true }, () => {
       Keyboard.keyPress(KEYS.ENTER);
     });
-    expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
   });
 
   it("should enter line editor on ctrl+dblclick (simple arrow)", () => {
     createTwoPointerLinearElement("arrow");
-    expect(h.state.editingLinearElement?.elementId).toBeUndefined();
+    expect(h.state.selectedLinearElement?.isEditing).toBe(false);
 
     Keyboard.withModifierKeys({ ctrl: true }, () => {
       mouse.doubleClick();
     });
-    expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
   });
 
   it("should enter line editor on ctrl+dblclick (line)", () => {
     createTwoPointerLinearElement("line");
-    expect(h.state.editingLinearElement?.elementId).toBeUndefined();
+    expect(h.state.selectedLinearElement?.isEditing).toBe(false);
 
     Keyboard.withModifierKeys({ ctrl: true }, () => {
       mouse.doubleClick();
     });
-    expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
   });
 
   it("should enter line editor on dblclick (line)", () => {
     createTwoPointerLinearElement("line");
-    expect(h.state.editingLinearElement?.elementId).toBeUndefined();
+    expect(h.state.selectedLinearElement?.isEditing).toBe(false);
 
     mouse.doubleClick();
-    expect(h.state.editingLinearElement?.elementId).toEqual(h.elements[0].id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(h.elements[0].id);
   });
 
   it("should not enter line editor on dblclick (arrow)", async () => {
     createTwoPointerLinearElement("arrow");
-    expect(h.state.editingLinearElement?.elementId).toBeUndefined();
+    expect(h.state.selectedLinearElement?.isEditing).toBe(false);
 
     mouse.doubleClick();
-    expect(h.state.editingLinearElement).toEqual(null);
+    expect(h.state.selectedLinearElement).toBe(null);
     await getTextEditor();
   });
 
@@ -330,10 +338,12 @@ describe("Test Linear Elements", () => {
     const arrow = h.elements[0] as ExcalidrawLinearElement;
     enterLineEditingMode(arrow);
 
-    expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
 
     mouse.doubleClick();
-    expect(h.state.editingLinearElement?.elementId).toEqual(arrow.id);
+    expect(h.state.selectedLinearElement?.isEditing).toBe(true);
+    expect(h.state.selectedLinearElement?.elementId).toEqual(arrow.id);
     expect(h.elements.length).toEqual(1);
 
     expect(document.querySelector(TEXT_EDITOR_SELECTOR)).toBe(null);
@@ -367,7 +377,7 @@ describe("Test Linear Elements", () => {
       // drag line from midpoint
       drag(midpoint, pointFrom(midpoint[0] + delta, midpoint[1] + delta));
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-        `12`,
+        `11`,
       );
       expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
 
@@ -469,7 +479,7 @@ describe("Test Linear Elements", () => {
       drag(startPoint, endPoint);
 
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-        `12`,
+        `11`,
       );
       expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
@@ -537,7 +547,7 @@ describe("Test Linear Elements", () => {
         );
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `16`,
+          `14`,
         );
         expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
@@ -588,7 +598,7 @@ describe("Test Linear Elements", () => {
         drag(hitCoords, pointFrom(hitCoords[0] - delta, hitCoords[1] - delta));
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `12`,
+          `11`,
         );
         expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
 
@@ -629,7 +639,7 @@ describe("Test Linear Elements", () => {
         drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `12`,
+          `11`,
         );
         expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
 
@@ -677,7 +687,7 @@ describe("Test Linear Elements", () => {
         deletePoint(points[2]);
         expect(line.points.length).toEqual(3);
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `18`,
+          `17`,
         );
         expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
@@ -735,7 +745,7 @@ describe("Test Linear Elements", () => {
           ),
         );
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `16`,
+          `14`,
         );
         expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
         expect(line.points.length).toEqual(5);
@@ -833,7 +843,7 @@ describe("Test Linear Elements", () => {
         drag(hitCoords, pointFrom(hitCoords[0] + delta, hitCoords[1] + delta));
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `12`,
+          `11`,
         );
         expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
 

+ 13 - 2
packages/excalidraw/actions/actionAlign.tsx

@@ -10,6 +10,8 @@ import { alignElements } from "@excalidraw/element";
 
 import { CaptureUpdateAction } from "@excalidraw/element";
 
+import { getSelectedElementsByGroup } from "@excalidraw/element";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import type { Alignment } from "@excalidraw/element";
@@ -38,7 +40,11 @@ export const alignActionsPredicate = (
 ) => {
   const selectedElements = app.scene.getSelectedElements(appState);
   return (
-    selectedElements.length > 1 &&
+    getSelectedElementsByGroup(
+      selectedElements,
+      app.scene.getNonDeletedElementsMap(),
+      appState as Readonly<AppState>,
+    ).length > 1 &&
     // TODO enable aligning frames when implemented properly
     !selectedElements.some((el) => isFrameLikeElement(el))
   );
@@ -52,7 +58,12 @@ const alignSelectedElements = (
 ) => {
   const selectedElements = app.scene.getSelectedElements(appState);
 
-  const updatedElements = alignElements(selectedElements, alignment, app.scene);
+  const updatedElements = alignElements(
+    selectedElements,
+    alignment,
+    app.scene,
+    appState,
+  );
 
   const updatedElementsMap = arrayToMap(updatedElements);
 

+ 19 - 12
packages/excalidraw/actions/actionDeleteSelected.tsx

@@ -205,16 +205,19 @@ export const actionDeleteSelected = register({
   icon: TrashIcon,
   trackEvent: { category: "element", action: "delete" },
   perform: (elements, appState, formData, app) => {
-    if (appState.editingLinearElement) {
+    if (appState.selectedLinearElement?.isEditing) {
       const {
         elementId,
         selectedPointsIndices,
         startBindingElement,
         endBindingElement,
-      } = appState.editingLinearElement;
+      } = appState.selectedLinearElement;
       const elementsMap = app.scene.getNonDeletedElementsMap();
-      const element = LinearElementEditor.getElement(elementId, elementsMap);
-      if (!element) {
+      const linearElement = LinearElementEditor.getElement(
+        elementId,
+        elementsMap,
+      );
+      if (!linearElement) {
         return false;
       }
       // case: no point selected → do nothing, as deleting the whole element
@@ -225,10 +228,10 @@ export const actionDeleteSelected = register({
         return false;
       }
 
-      // case: deleting last remaining point
-      if (element.points.length < 2) {
+      // case: deleting all points
+      if (selectedPointsIndices.length >= linearElement.points.length) {
         const nextElements = elements.map((el) => {
-          if (el.id === element.id) {
+          if (el.id === linearElement.id) {
             return newElementWith(el, { isDeleted: true });
           }
           return el;
@@ -239,7 +242,7 @@ export const actionDeleteSelected = register({
           elements: nextElements,
           appState: {
             ...nextAppState,
-            editingLinearElement: null,
+            selectedLinearElement: null,
           },
           captureUpdate: CaptureUpdateAction.IMMEDIATELY,
         };
@@ -252,20 +255,24 @@ export const actionDeleteSelected = register({
           ? null
           : startBindingElement,
         endBindingElement: selectedPointsIndices?.includes(
-          element.points.length - 1,
+          linearElement.points.length - 1,
         )
           ? null
           : endBindingElement,
       };
 
-      LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
+      LinearElementEditor.deletePoints(
+        linearElement,
+        app,
+        selectedPointsIndices,
+      );
 
       return {
         elements,
         appState: {
           ...appState,
-          editingLinearElement: {
-            ...appState.editingLinearElement,
+          selectedLinearElement: {
+            ...appState.selectedLinearElement,
             ...binding,
             selectedPointsIndices:
               selectedPointsIndices?.[0] > 0

+ 8 - 1
packages/excalidraw/actions/actionDistribute.tsx

@@ -10,6 +10,8 @@ import { distributeElements } from "@excalidraw/element";
 
 import { CaptureUpdateAction } from "@excalidraw/element";
 
+import { getSelectedElementsByGroup } from "@excalidraw/element";
+
 import type { ExcalidrawElement } from "@excalidraw/element/types";
 
 import type { Distribution } from "@excalidraw/element";
@@ -31,7 +33,11 @@ import type { AppClassProperties, AppState } from "../types";
 const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
   const selectedElements = app.scene.getSelectedElements(appState);
   return (
-    selectedElements.length > 1 &&
+    getSelectedElementsByGroup(
+      selectedElements,
+      app.scene.getNonDeletedElementsMap(),
+      appState as Readonly<AppState>,
+    ).length > 2 &&
     // TODO enable distributing frames when implemented properly
     !selectedElements.some((el) => isFrameLikeElement(el))
   );
@@ -49,6 +55,7 @@ const distributeSelectedElements = (
     selectedElements,
     app.scene.getNonDeletedElementsMap(),
     distribution,
+    appState,
   );
 
   const updatedElementsMap = arrayToMap(updatedElements);

+ 1 - 1
packages/excalidraw/actions/actionDuplicateSelection.tsx

@@ -39,7 +39,7 @@ export const actionDuplicateSelection = register({
     }
 
     // duplicate selected point(s) if editing a line
-    if (appState.editingLinearElement) {
+    if (appState.selectedLinearElement?.isEditing) {
       // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
       try {
         const newAppState = LinearElementEditor.duplicateSelectedPoints(

+ 9 - 9
packages/excalidraw/actions/actionFinalize.tsx

@@ -94,9 +94,9 @@ export const actionFinalize = register({
       }
     }
 
-    if (appState.editingLinearElement) {
+    if (appState.selectedLinearElement?.isEditing) {
       const { elementId, startBindingElement, endBindingElement } =
-        appState.editingLinearElement;
+        appState.selectedLinearElement;
       const element = LinearElementEditor.getElement(elementId, elementsMap);
 
       if (element) {
@@ -122,7 +122,11 @@ export const actionFinalize = register({
           appState: {
             ...appState,
             cursorButton: "up",
-            editingLinearElement: null,
+            selectedLinearElement: new LinearElementEditor(
+              element,
+              arrayToMap(elementsMap),
+              false, // exit editing mode
+            ),
           },
           captureUpdate: CaptureUpdateAction.IMMEDIATELY,
         };
@@ -154,11 +158,7 @@ export const actionFinalize = register({
 
     if (element) {
       // pen and mouse have hover
-      if (
-        appState.multiElement &&
-        element.type !== "freedraw" &&
-        appState.lastPointerDownWith !== "touch"
-      ) {
+      if (appState.multiElement && element.type !== "freedraw") {
         const { points, lastCommittedPoint } = element;
         if (
           !lastCommittedPoint ||
@@ -289,7 +289,7 @@ export const actionFinalize = register({
   },
   keyTest: (event, appState) =>
     (event.key === KEYS.ESCAPE &&
-      (appState.editingLinearElement !== null ||
+      (appState.selectedLinearElement?.isEditing ||
         (!appState.newElement && appState.multiElement === null))) ||
     ((event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) &&
       appState.multiElement !== null),

+ 18 - 8
packages/excalidraw/actions/actionLinearEditor.tsx

@@ -1,10 +1,9 @@
-import { LinearElementEditor } from "@excalidraw/element";
 import {
   isElbowArrow,
   isLinearElement,
   isLineElement,
 } from "@excalidraw/element";
-import { arrayToMap } from "@excalidraw/common";
+import { arrayToMap, invariant } from "@excalidraw/common";
 
 import {
   toggleLinePolygonState,
@@ -46,7 +45,7 @@ export const actionToggleLinearEditor = register({
   predicate: (elements, appState, _, app) => {
     const selectedElements = app.scene.getSelectedElements(appState);
     if (
-      !appState.editingLinearElement &&
+      !appState.selectedLinearElement?.isEditing &&
       selectedElements.length === 1 &&
       isLinearElement(selectedElements[0]) &&
       !isElbowArrow(selectedElements[0])
@@ -61,14 +60,25 @@ export const actionToggleLinearEditor = register({
       includeBoundTextElement: true,
     })[0] as ExcalidrawLinearElement;
 
-    const editingLinearElement =
-      appState.editingLinearElement?.elementId === selectedElement.id
-        ? null
-        : new LinearElementEditor(selectedElement, arrayToMap(elements));
+    invariant(selectedElement, "No selected element found");
+    invariant(
+      appState.selectedLinearElement,
+      "No selected linear element found",
+    );
+    invariant(
+      selectedElement.id === appState.selectedLinearElement.elementId,
+      "Selected element ID and linear editor elementId does not match",
+    );
+
+    const selectedLinearElement = {
+      ...appState.selectedLinearElement,
+      isEditing: !appState.selectedLinearElement.isEditing,
+    };
+
     return {
       appState: {
         ...appState,
-        editingLinearElement,
+        selectedLinearElement,
       },
       captureUpdate: CaptureUpdateAction.IMMEDIATELY,
     };

+ 1 - 1
packages/excalidraw/actions/actionSelectAll.ts

@@ -21,7 +21,7 @@ export const actionSelectAll = register({
   trackEvent: { category: "canvas" },
   viewMode: false,
   perform: (elements, appState, value, app) => {
-    if (appState.editingLinearElement) {
+    if (appState.selectedLinearElement?.isEditing) {
       return false;
     }
 

+ 0 - 2
packages/excalidraw/appState.ts

@@ -48,7 +48,6 @@ export const getDefaultAppState = (): Omit<
     newElement: null,
     editingTextElement: null,
     editingGroupId: null,
-    editingLinearElement: null,
     activeTool: {
       type: "selection",
       customType: null,
@@ -175,7 +174,6 @@ const APP_STATE_STORAGE_CONF = (<
   newElement: { browser: false, export: false, server: false },
   editingTextElement: { browser: false, export: false, server: false },
   editingGroupId: { browser: true, export: false, server: false },
-  editingLinearElement: { browser: false, export: false, server: false },
   activeTool: { browser: true, export: false, server: false },
   penMode: { browser: true, export: false, server: false },
   penDetected: { browser: true, export: false, server: false },

+ 1 - 13
packages/excalidraw/components/Actions.tsx

@@ -141,7 +141,7 @@ export const SelectedShapeActions = ({
     targetElements.length === 1 || isSingleElementBoundContainer;
 
   const showLineEditorAction =
-    !appState.editingLinearElement &&
+    !appState.selectedLinearElement?.isEditing &&
     targetElements.length === 1 &&
     isLinearElement(targetElements[0]) &&
     !isElbowArrow(targetElements[0]);
@@ -524,15 +524,3 @@ export const ExitZenModeAction = ({
     {t("buttons.exitZenMode")}
   </button>
 );
-
-export const FinalizeAction = ({
-  renderAction,
-  className,
-}: {
-  renderAction: ActionManager["renderAction"];
-  className?: string;
-}) => (
-  <div className={`finalize-button ${className}`}>
-    {renderAction("finalize", { size: "small" })}
-  </div>
-);

+ 152 - 126
packages/excalidraw/components/App.tsx

@@ -101,6 +101,7 @@ import {
   CLASSES,
   Emitter,
   isMobile,
+  MINIMUM_ARROW_SIZE,
 } from "@excalidraw/common";
 
 import {
@@ -2164,9 +2165,14 @@ class App extends React.Component<AppProps, AppState> {
 
   public dismissLinearEditor = () => {
     setTimeout(() => {
-      this.setState({
-        editingLinearElement: null,
-      });
+      if (this.state.selectedLinearElement?.isEditing) {
+        this.setState({
+          selectedLinearElement: {
+            ...this.state.selectedLinearElement,
+            isEditing: false,
+          },
+        });
+      }
     });
   };
 
@@ -2877,15 +2883,15 @@ class App extends React.Component<AppProps, AppState> {
     );
 
     if (
-      this.state.editingLinearElement &&
-      !this.state.selectedElementIds[this.state.editingLinearElement.elementId]
+      this.state.selectedLinearElement?.isEditing &&
+      !this.state.selectedElementIds[this.state.selectedLinearElement.elementId]
     ) {
       // defer so that the scheduleCapture flag isn't reset via current update
       setTimeout(() => {
         // execute only if the condition still holds when the deferred callback
         // executes (it can be scheduled multiple times depending on how
         // many times the component renders)
-        this.state.editingLinearElement &&
+        this.state.selectedLinearElement?.isEditing &&
           this.actionManager.executeAction(actionFinalize);
       });
     }
@@ -4440,17 +4446,13 @@ class App extends React.Component<AppProps, AppState> {
           if (event[KEYS.CTRL_OR_CMD] || isLineElement(selectedElement)) {
             if (isLinearElement(selectedElement)) {
               if (
-                !this.state.editingLinearElement ||
-                this.state.editingLinearElement.elementId !== selectedElement.id
+                !this.state.selectedLinearElement?.isEditing ||
+                this.state.selectedLinearElement.elementId !==
+                  selectedElement.id
               ) {
                 this.store.scheduleCapture();
                 if (!isElbowArrow(selectedElement)) {
-                  this.setState({
-                    editingLinearElement: new LinearElementEditor(
-                      selectedElement,
-                      this.scene.getNonDeletedElementsMap(),
-                    ),
-                  });
+                  this.actionManager.executeAction(actionToggleLinearEditor);
                 }
               }
             }
@@ -4947,7 +4949,17 @@ class App extends React.Component<AppProps, AppState> {
       }),
       onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
         const isDeleted = !nextOriginalText.trim();
-        updateElement(nextOriginalText, isDeleted);
+
+        if (isDeleted && !isExistingElement) {
+          // let's just remove the element from the scene, as it's an empty just created text element
+          this.scene.replaceAllElements(
+            this.scene
+              .getElementsIncludingDeleted()
+              .filter((x) => x.id !== element.id),
+          );
+        } else {
+          updateElement(nextOriginalText, isDeleted);
+        }
         // select the created text element only if submitting via keyboard
         // (when submitting via click it should act as signal to deselect)
         if (!isDeleted && viaKeyboard) {
@@ -4976,9 +4988,10 @@ class App extends React.Component<AppProps, AppState> {
             element,
           ]);
         }
-        if (!isDeleted || isExistingElement) {
-          this.store.scheduleCapture();
-        }
+
+        // we need to record either way, whether the text element was added or removed
+        // since we need to sync this delta to other clients, otherwise it would end up with inconsistencies
+        this.store.scheduleCapture();
 
         flushSync(() => {
           this.setState({
@@ -5442,15 +5455,12 @@ class App extends React.Component<AppProps, AppState> {
       if (
         ((event[KEYS.CTRL_OR_CMD] && isSimpleArrow(selectedLinearElement)) ||
           isLineElement(selectedLinearElement)) &&
-        this.state.editingLinearElement?.elementId !== selectedLinearElement.id
+        (!this.state.selectedLinearElement?.isEditing ||
+          this.state.selectedLinearElement.elementId !==
+            selectedLinearElement.id)
       ) {
-        this.store.scheduleCapture();
-        this.setState({
-          editingLinearElement: new LinearElementEditor(
-            selectedLinearElement,
-            this.scene.getNonDeletedElementsMap(),
-          ),
-        });
+        // Use the proper action to ensure immediate history capture
+        this.actionManager.executeAction(actionToggleLinearEditor);
         return;
       } else if (
         this.state.selectedLinearElement &&
@@ -5515,8 +5525,8 @@ class App extends React.Component<AppProps, AppState> {
           return;
         }
       } else if (
-        this.state.editingLinearElement &&
-        this.state.editingLinearElement.elementId ===
+        this.state.selectedLinearElement?.isEditing &&
+        this.state.selectedLinearElement.elementId ===
           selectedLinearElement.id &&
         isLineElement(selectedLinearElement)
       ) {
@@ -5571,7 +5581,7 @@ class App extends React.Component<AppProps, AppState> {
 
       // shouldn't edit/create text when inside line editor (often false positive)
 
-      if (!this.state.editingLinearElement) {
+      if (!this.state.selectedLinearElement?.isEditing) {
         const container = this.getTextBindableContainerAtPosition(
           sceneX,
           sceneY,
@@ -5869,8 +5879,8 @@ class App extends React.Component<AppProps, AppState> {
     }
 
     if (
-      this.state.editingLinearElement &&
-      !this.state.editingLinearElement.isDragging
+      this.state.selectedLinearElement?.isEditing &&
+      !this.state.selectedLinearElement.isDragging
     ) {
       const editingLinearElement = LinearElementEditor.handlePointerMove(
         event,
@@ -5878,30 +5888,34 @@ class App extends React.Component<AppProps, AppState> {
         scenePointerY,
         this,
       );
+      const linearElement = editingLinearElement
+        ? this.scene.getElement(editingLinearElement.elementId)
+        : null;
 
       if (
         editingLinearElement &&
-        editingLinearElement !== this.state.editingLinearElement
+        editingLinearElement !== this.state.selectedLinearElement
       ) {
         // Since we are reading from previous state which is not possible with
         // automatic batching in React 18 hence using flush sync to synchronously
         // update the state. Check https://github.com/excalidraw/excalidraw/pull/5508 for more details.
         flushSync(() => {
           this.setState({
-            editingLinearElement,
+            selectedLinearElement: editingLinearElement,
           });
         });
       }
-      if (editingLinearElement?.lastUncommittedPoint != null) {
+      if (
+        editingLinearElement?.lastUncommittedPoint != null &&
+        linearElement &&
+        isBindingElementType(linearElement.type)
+      ) {
         this.maybeSuggestBindingAtCursor(
           scenePointer,
           editingLinearElement.elbowed,
         );
-      } else {
-        // causes stack overflow if not sync
-        flushSync(() => {
-          this.setState({ suggestedBindings: [] });
-        });
+      } else if (this.state.suggestedBindings.length) {
+        this.setState({ suggestedBindings: [] });
       }
     }
 
@@ -6048,7 +6062,7 @@ class App extends React.Component<AppProps, AppState> {
     if (
       selectedElements.length === 1 &&
       !isOverScrollBar &&
-      !this.state.editingLinearElement
+      !this.state.selectedLinearElement?.isEditing
     ) {
       // for linear elements, we'd like to prioritize point dragging over edge resizing
       // therefore, we update and check hovered point index first
@@ -6166,15 +6180,6 @@ class App extends React.Component<AppProps, AppState> {
         setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
       } else if (isOverScrollBar) {
         setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
-      } else if (
-        this.state.selectedLinearElement &&
-        hitElement?.id === this.state.selectedLinearElement.elementId
-      ) {
-        this.handleHoverSelectedLinearElement(
-          this.state.selectedLinearElement,
-          scenePointerX,
-          scenePointerY,
-        );
       } else if (
         // if using cmd/ctrl, we're not dragging
         !event[KEYS.CTRL_OR_CMD]
@@ -6221,6 +6226,14 @@ class App extends React.Component<AppProps, AppState> {
       } else {
         setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
       }
+
+      if (this.state.selectedLinearElement) {
+        this.handleHoverSelectedLinearElement(
+          this.state.selectedLinearElement,
+          scenePointerX,
+          scenePointerY,
+        );
+      }
     }
 
     if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) {
@@ -7180,7 +7193,7 @@ class App extends React.Component<AppProps, AppState> {
 
       if (
         selectedElements.length === 1 &&
-        !this.state.editingLinearElement &&
+        !this.state.selectedLinearElement?.isEditing &&
         !isElbowArrow(selectedElements[0]) &&
         !(
           this.state.selectedLinearElement &&
@@ -7251,8 +7264,7 @@ class App extends React.Component<AppProps, AppState> {
         }
       } else {
         if (this.state.selectedLinearElement) {
-          const linearElementEditor =
-            this.state.editingLinearElement || this.state.selectedLinearElement;
+          const linearElementEditor = this.state.selectedLinearElement;
           const ret = LinearElementEditor.handlePointerDown(
             event,
             this,
@@ -7266,10 +7278,6 @@ class App extends React.Component<AppProps, AppState> {
           }
           if (ret.linearElementEditor) {
             this.setState({ selectedLinearElement: ret.linearElementEditor });
-
-            if (this.state.editingLinearElement) {
-              this.setState({ editingLinearElement: ret.linearElementEditor });
-            }
           }
           if (ret.didAddPoint) {
             return true;
@@ -7370,11 +7378,11 @@ class App extends React.Component<AppProps, AppState> {
           this.clearSelection(hitElement);
         }
 
-        if (this.state.editingLinearElement) {
+        if (this.state.selectedLinearElement?.isEditing) {
           this.setState({
             selectedElementIds: makeNextSelectedElementIds(
               {
-                [this.state.editingLinearElement.elementId]: true,
+                [this.state.selectedLinearElement.elementId]: true,
               },
               this.state,
             ),
@@ -8246,16 +8254,12 @@ class App extends React.Component<AppProps, AppState> {
           this.scene,
         );
 
-        flushSync(() => {
-          if (this.state.selectedLinearElement) {
-            this.setState({
-              selectedLinearElement: {
-                ...this.state.selectedLinearElement,
-                segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
-                pointerDownState: ret.pointerDownState,
-              },
-            });
-          }
+        this.setState({
+          selectedLinearElement: {
+            ...this.state.selectedLinearElement,
+            segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords,
+            pointerDownState: ret.pointerDownState,
+          },
         });
         return;
       }
@@ -8314,7 +8318,9 @@ class App extends React.Component<AppProps, AppState> {
           pointDistance(
             pointFrom(pointerCoords.x, pointerCoords.y),
             pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
-          ) < DRAGGING_THRESHOLD
+          ) *
+            this.state.zoom.value <
+          MINIMUM_ARROW_SIZE
         ) {
           return;
         }
@@ -8332,8 +8338,7 @@ class App extends React.Component<AppProps, AppState> {
       const elementsMap = this.scene.getNonDeletedElementsMap();
 
       if (this.state.selectedLinearElement) {
-        const linearElementEditor =
-          this.state.editingLinearElement || this.state.selectedLinearElement;
+        const linearElementEditor = this.state.selectedLinearElement;
 
         if (
           LinearElementEditor.shouldAddMidpoint(
@@ -8369,16 +8374,6 @@ class App extends React.Component<AppProps, AppState> {
                 },
               });
             }
-            if (this.state.editingLinearElement) {
-              this.setState({
-                editingLinearElement: {
-                  ...this.state.editingLinearElement,
-                  pointerDownState: ret.pointerDownState,
-                  selectedPointsIndices: ret.selectedPointsIndices,
-                  segmentMidPointHoveredCoords: null,
-                },
-              });
-            }
           });
 
           return;
@@ -8412,9 +8407,9 @@ class App extends React.Component<AppProps, AppState> {
       );
 
       const isSelectingPointsInLineEditor =
-        this.state.editingLinearElement &&
+        this.state.selectedLinearElement?.isEditing &&
         event.shiftKey &&
-        this.state.editingLinearElement.elementId ===
+        this.state.selectedLinearElement.elementId ===
           pointerDownState.hit.element?.id;
       if (
         (hasHitASelectedElement ||
@@ -8780,23 +8775,21 @@ class App extends React.Component<AppProps, AppState> {
         pointerDownState.lastCoords.x = pointerCoords.x;
         pointerDownState.lastCoords.y = pointerCoords.y;
         if (event.altKey) {
-          flushSync(() => {
-            this.setActiveTool(
-              { type: "lasso", fromSelection: true },
-              event.shiftKey,
-            );
-            this.lassoTrail.startPath(
-              pointerDownState.origin.x,
-              pointerDownState.origin.y,
-              event.shiftKey,
-            );
-            this.setAppState({
-              selectionElement: null,
-            });
+          this.setActiveTool(
+            { type: "lasso", fromSelection: true },
+            event.shiftKey,
+          );
+          this.lassoTrail.startPath(
+            pointerDownState.origin.x,
+            pointerDownState.origin.y,
+            event.shiftKey,
+          );
+          this.setAppState({
+            selectionElement: null,
           });
-        } else {
-          this.maybeDragNewGenericElement(pointerDownState, event);
+          return;
         }
+        this.maybeDragNewGenericElement(pointerDownState, event);
       } else if (this.state.activeTool.type === "lasso") {
         if (!event.altKey && this.state.activeTool.fromSelection) {
           this.setActiveTool({ type: "selection" });
@@ -8916,7 +8909,7 @@ class App extends React.Component<AppProps, AppState> {
         const elements = this.scene.getNonDeletedElements();
 
         // box-select line editor points
-        if (this.state.editingLinearElement) {
+        if (this.state.selectedLinearElement?.isEditing) {
           LinearElementEditor.handleBoxSelection(
             event,
             this.state,
@@ -9160,23 +9153,23 @@ class App extends React.Component<AppProps, AppState> {
 
       // Handle end of dragging a point of a linear element, might close a loop
       // and sets binding element
-      if (this.state.editingLinearElement) {
+      if (this.state.selectedLinearElement?.isEditing) {
         if (
           !pointerDownState.boxSelection.hasOccurred &&
           pointerDownState.hit?.element?.id !==
-            this.state.editingLinearElement.elementId
+            this.state.selectedLinearElement.elementId
         ) {
           this.actionManager.executeAction(actionFinalize);
         } else {
           const editingLinearElement = LinearElementEditor.handlePointerUp(
             childEvent,
-            this.state.editingLinearElement,
+            this.state.selectedLinearElement,
             this.state,
             this.scene,
           );
-          if (editingLinearElement !== this.state.editingLinearElement) {
+          if (editingLinearElement !== this.state.selectedLinearElement) {
             this.setState({
-              editingLinearElement,
+              selectedLinearElement: editingLinearElement,
               suggestedBindings: [],
             });
           }
@@ -9279,25 +9272,54 @@ class App extends React.Component<AppProps, AppState> {
           this.state,
         );
 
-        if (!pointerDownState.drag.hasOccurred && newElement && !multiElement) {
-          this.scene.mutateElement(
-            newElement,
-            {
-              points: [
-                ...newElement.points,
-                pointFrom<LocalPoint>(
-                  pointerCoords.x - newElement.x,
-                  pointerCoords.y - newElement.y,
-                ),
-              ],
-            },
-            { informMutation: false, isDragging: false },
-          );
+        const dragDistance =
+          pointDistance(
+            pointFrom(pointerCoords.x, pointerCoords.y),
+            pointFrom(pointerDownState.origin.x, pointerDownState.origin.y),
+          ) * this.state.zoom.value;
 
-          this.setState({
-            multiElement: newElement,
-            newElement,
-          });
+        if (
+          (!pointerDownState.drag.hasOccurred ||
+            dragDistance < MINIMUM_ARROW_SIZE) &&
+          newElement &&
+          !multiElement
+        ) {
+          if (this.device.isTouchScreen) {
+            const FIXED_DELTA_X = Math.min(
+              (this.state.width * 0.7) / this.state.zoom.value,
+              100,
+            );
+
+            this.scene.mutateElement(
+              newElement,
+              {
+                x: newElement.x - FIXED_DELTA_X / 2,
+                points: [
+                  pointFrom<LocalPoint>(0, 0),
+                  pointFrom<LocalPoint>(FIXED_DELTA_X, 0),
+                ],
+              },
+              { informMutation: false, isDragging: false },
+            );
+
+            this.actionManager.executeAction(actionFinalize);
+          } else {
+            const dx = pointerCoords.x - newElement.x;
+            const dy = pointerCoords.y - newElement.y;
+
+            this.scene.mutateElement(
+              newElement,
+              {
+                points: [...newElement.points, pointFrom<LocalPoint>(dx, dy)],
+              },
+              { informMutation: false, isDragging: false },
+            );
+
+            this.setState({
+              multiElement: newElement,
+              newElement,
+            });
+          }
         } else if (pointerDownState.drag.hasOccurred && !multiElement) {
           if (
             isBindingEnabled(this.state) &&
@@ -9660,14 +9682,17 @@ class App extends React.Component<AppProps, AppState> {
         !pointerDownState.hit.wasAddedToSelection &&
         // if we're editing a line, pointerup shouldn't switch selection if
         // box selected
-        (!this.state.editingLinearElement ||
+        (!this.state.selectedLinearElement?.isEditing ||
           !pointerDownState.boxSelection.hasOccurred) &&
         // hitElement can be set when alt + ctrl to toggle lasso and we will
         // just respect the selected elements from lasso instead
         this.state.activeTool.type !== "lasso"
       ) {
         // when inside line editor, shift selects points instead
-        if (childEvent.shiftKey && !this.state.editingLinearElement) {
+        if (
+          childEvent.shiftKey &&
+          !this.state.selectedLinearElement?.isEditing
+        ) {
           if (this.state.selectedElementIds[hitElement.id]) {
             if (isSelectedViaGroup(this.state, hitElement)) {
               this.setState((_prevState) => {
@@ -9845,8 +9870,9 @@ class App extends React.Component<AppProps, AppState> {
           (!hitElement &&
             pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
       ) {
-        if (this.state.editingLinearElement) {
-          this.setState({ editingLinearElement: null });
+        if (this.state.selectedLinearElement?.isEditing) {
+          // Exit editing mode but keep the element selected
+          this.actionManager.executeAction(actionToggleLinearEditor);
         } else {
           // Deselect selected elements
           this.setState({

+ 1 - 0
packages/excalidraw/components/CommandPalette/CommandPalette.scss

@@ -108,6 +108,7 @@ $verticalBreakpoint: 861px;
           display: flex;
           align-items: center;
           gap: 0.25rem;
+          overflow: hidden;
         }
       }
 

+ 3 - 1
packages/excalidraw/components/CommandPalette/CommandPalette.tsx

@@ -59,6 +59,8 @@ import { useStableCallback } from "../../hooks/useStableCallback";
 import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
 import { useStable } from "../../hooks/useStable";
 
+import { Ellipsify } from "../Ellipsify";
+
 import * as defaultItems from "./defaultCommandPaletteItems";
 
 import "./CommandPalette.scss";
@@ -964,7 +966,7 @@ const CommandItem = ({
             }
           />
         )}
-        {command.label}
+        <Ellipsify>{command.label}</Ellipsify>
       </div>
       {showShortcut && command.shortcut && (
         <CommandShortcutHint shortcut={command.shortcut} />

+ 18 - 0
packages/excalidraw/components/Ellipsify.tsx

@@ -0,0 +1,18 @@
+export const Ellipsify = ({
+  children,
+  ...rest
+}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) => {
+  return (
+    <span
+      {...rest}
+      style={{
+        textOverflow: "ellipsis",
+        overflow: "hidden",
+        whiteSpace: "nowrap",
+        ...rest.style,
+      }}
+    >
+      {children}
+    </span>
+  );
+};

+ 3 - 3
packages/excalidraw/components/HintViewer.tsx

@@ -115,7 +115,7 @@ const getHints = ({
       appState.selectionElement &&
       !selectedElements.length &&
       !appState.editingTextElement &&
-      !appState.editingLinearElement
+      !appState.selectedLinearElement?.isEditing
     ) {
       return [t("hints.deepBoxSelect")];
     }
@@ -130,8 +130,8 @@ const getHints = ({
 
     if (selectedElements.length === 1) {
       if (isLinearElement(selectedElements[0])) {
-        if (appState.editingLinearElement) {
-          return appState.editingLinearElement.selectedPointsIndices
+        if (appState.selectedLinearElement?.isEditing) {
+          return appState.selectedLinearElement.selectedPointsIndices
             ? t("hints.lineEditor_pointSelected")
             : t("hints.lineEditor_nothingSelected");
         }

+ 1 - 0
packages/excalidraw/components/InlineIcon.tsx

@@ -7,6 +7,7 @@ export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
         display: "inline-block",
         lineHeight: 0,
         verticalAlign: "middle",
+        flex: "0 0 auto",
       }}
     >
       {icon}

+ 0 - 1
packages/excalidraw/components/canvases/InteractiveCanvas.tsx

@@ -192,7 +192,6 @@ const getRelevantAppStateProps = (
   viewModeEnabled: appState.viewModeEnabled,
   openDialog: appState.openDialog,
   editingGroupId: appState.editingGroupId,
-  editingLinearElement: appState.editingLinearElement,
   selectedElementIds: appState.selectedElementIds,
   frameToHighlight: appState.frameToHighlight,
   offsetLeft: appState.offsetLeft,

+ 7 - 20
packages/excalidraw/components/canvases/StaticCanvas.tsx

@@ -34,6 +34,13 @@ const StaticCanvas = (props: StaticCanvasProps) => {
   const wrapperRef = useRef<HTMLDivElement>(null);
   const isComponentMounted = useRef(false);
 
+  useEffect(() => {
+    props.canvas.style.width = `${props.appState.width}px`;
+    props.canvas.style.height = `${props.appState.height}px`;
+    props.canvas.width = props.appState.width * props.scale;
+    props.canvas.height = props.appState.height * props.scale;
+  }, [props.appState.height, props.appState.width, props.canvas, props.scale]);
+
   useEffect(() => {
     const wrapper = wrapperRef.current;
     if (!wrapper) {
@@ -49,26 +56,6 @@ const StaticCanvas = (props: StaticCanvasProps) => {
       canvas.classList.add("excalidraw__canvas", "static");
     }
 
-    const widthString = `${props.appState.width}px`;
-    const heightString = `${props.appState.height}px`;
-    if (canvas.style.width !== widthString) {
-      canvas.style.width = widthString;
-    }
-    if (canvas.style.height !== heightString) {
-      canvas.style.height = heightString;
-    }
-
-    const scaledWidth = props.appState.width * props.scale;
-    const scaledHeight = props.appState.height * props.scale;
-    // setting width/height resets the canvas even if dimensions not changed,
-    // which would cause flicker when we skip frame (due to throttling)
-    if (canvas.width !== scaledWidth) {
-      canvas.width = scaledWidth;
-    }
-    if (canvas.height !== scaledHeight) {
-      canvas.height = scaledHeight;
-    }
-
     renderStaticScene(
       {
         canvas,

+ 3 - 0
packages/excalidraw/components/dropdownMenu/DropdownMenu.scss

@@ -19,6 +19,8 @@
         border-radius: var(--border-radius-lg);
         position: relative;
         transition: box-shadow 0.5s ease-in-out;
+        display: flex;
+        flex-direction: column;
 
         &.zen-mode {
           box-shadow: none;
@@ -100,6 +102,7 @@
       align-items: center;
       cursor: pointer;
       border-radius: var(--border-radius-md);
+      flex: 1 0 auto;
 
       @media screen and (min-width: 1921px) {
         height: 2.25rem;

+ 3 - 1
packages/excalidraw/components/dropdownMenu/DropdownMenuItemContent.tsx

@@ -1,5 +1,7 @@
 import { useDevice } from "../App";
 
+import { Ellipsify } from "../Ellipsify";
+
 import type { JSX } from "react";
 
 const MenuItemContent = ({
@@ -18,7 +20,7 @@ const MenuItemContent = ({
     <>
       {icon && <div className="dropdown-menu-item__icon">{icon}</div>}
       <div style={textStyle} className="dropdown-menu-item__text">
-        {children}
+        <Ellipsify>{children}</Ellipsify>
       </div>
       {shortcut && !device.editor.isMobile && (
         <div className="dropdown-menu-item__shortcut">{shortcut}</div>

+ 1 - 20
packages/excalidraw/components/footer/Footer.tsx

@@ -2,13 +2,7 @@ import clsx from "clsx";
 
 import { actionShortcuts } from "../../actions";
 import { useTunnels } from "../../context/tunnels";
-import {
-  ExitZenModeAction,
-  FinalizeAction,
-  UndoRedoActions,
-  ZoomActions,
-} from "../Actions";
-import { useDevice } from "../App";
+import { ExitZenModeAction, UndoRedoActions, ZoomActions } from "../Actions";
 import { HelpButton } from "../HelpButton";
 import { Section } from "../Section";
 import Stack from "../Stack";
@@ -29,10 +23,6 @@ const Footer = ({
 }) => {
   const { FooterCenterTunnel, WelcomeScreenHelpHintTunnel } = useTunnels();
 
-  const device = useDevice();
-  const showFinalize =
-    !appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
-
   return (
     <footer
       role="contentinfo"
@@ -60,15 +50,6 @@ const Footer = ({
                 })}
               />
             )}
-            {showFinalize && (
-              <FinalizeAction
-                renderAction={actionManager.renderAction}
-                className={clsx("zen-mode-transition", {
-                  "layer-ui__wrapper__footer-left--transition-left":
-                    appState.zenModeEnabled,
-                })}
-              />
-            )}
           </Section>
         </Stack.Col>
       </div>

+ 1 - 0
packages/excalidraw/index.tsx

@@ -281,6 +281,7 @@ export { Sidebar } from "./components/Sidebar/Sidebar";
 export { Button } from "./components/Button";
 export { Footer };
 export { MainMenu };
+export { Ellipsify } from "./components/Ellipsify";
 export { useDevice } from "./components/App";
 export { WelcomeScreen };
 export { LiveCollaborationTrigger };

+ 16 - 9
packages/excalidraw/renderer/interactiveScene.ts

@@ -118,7 +118,8 @@ const renderLinearElementPointHighlight = (
 ) => {
   const { elementId, hoverPointIndex } = appState.selectedLinearElement!;
   if (
-    appState.editingLinearElement?.selectedPointsIndices?.includes(
+    appState.selectedLinearElement?.isEditing &&
+    appState.selectedLinearElement?.selectedPointsIndices?.includes(
       hoverPointIndex,
     )
   ) {
@@ -180,7 +181,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
     point[0],
     point[1],
     (isOverlappingPoint
-      ? radius * (appState.editingLinearElement ? 1.5 : 2)
+      ? radius * (appState.selectedLinearElement?.isEditing ? 1.5 : 2)
       : radius) / appState.zoom.value,
     !isPhantomPoint,
     !isOverlappingPoint || isSelected,
@@ -448,7 +449,7 @@ const renderLinearPointHandles = (
   );
 
   const { POINT_HANDLE_SIZE } = LinearElementEditor;
-  const radius = appState.editingLinearElement
+  const radius = appState.selectedLinearElement?.isEditing
     ? POINT_HANDLE_SIZE
     : POINT_HANDLE_SIZE / 2;
 
@@ -470,7 +471,8 @@ const renderLinearPointHandles = (
       );
 
     let isSelected =
-      !!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
+      !!appState.selectedLinearElement?.isEditing &&
+      !!appState.selectedLinearElement?.selectedPointsIndices?.includes(idx);
     // when element is a polygon, highlight the last point as well if first
     // point is selected since they overlap and the last point tends to be
     // rendered on top
@@ -479,7 +481,8 @@ const renderLinearPointHandles = (
       element.polygon &&
       !isSelected &&
       idx === element.points.length - 1 &&
-      !!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
+      !!appState.selectedLinearElement?.isEditing &&
+      !!appState.selectedLinearElement?.selectedPointsIndices?.includes(0)
     ) {
       isSelected = true;
     }
@@ -535,7 +538,7 @@ const renderLinearPointHandles = (
     );
 
     midPoints.forEach((segmentMidPoint) => {
-      if (appState.editingLinearElement || points.length === 2) {
+      if (appState.selectedLinearElement?.isEditing || points.length === 2) {
         renderSingleLinearPoint(
           context,
           appState,
@@ -760,7 +763,10 @@ const _renderInteractiveScene = ({
     // Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
     // ShapeCache returns empty hence making sure that we get the
     // correct element from visible elements
-    if (appState.editingLinearElement?.elementId === element.id) {
+    if (
+      appState.selectedLinearElement?.isEditing &&
+      appState.selectedLinearElement.elementId === element.id
+    ) {
       if (element) {
         editingLinearElement = element as NonDeleted<ExcalidrawLinearElement>;
       }
@@ -853,7 +859,8 @@ const _renderInteractiveScene = ({
   // correct element from visible elements
   if (
     selectedElements.length === 1 &&
-    appState.editingLinearElement?.elementId === selectedElements[0].id
+    appState.selectedLinearElement?.isEditing &&
+    appState.selectedLinearElement.elementId === selectedElements[0].id
   ) {
     renderLinearPointHandles(
       context,
@@ -884,7 +891,7 @@ const _renderInteractiveScene = ({
   }
 
   // Paint selected elements
-  if (!appState.multiElement && !appState.editingLinearElement) {
+  if (!appState.multiElement && !appState.selectedLinearElement?.isEditing) {
     const showBoundingBox = shouldShowBoundingBox(selectedElements, appState);
 
     const isSingleLinearElementSelected =

+ 9 - 1
packages/excalidraw/renderer/renderNewElementScene.ts

@@ -1,6 +1,6 @@
 import { throttleRAF } from "@excalidraw/common";
 
-import { renderElement } from "@excalidraw/element";
+import { isInvisiblySmallElement, renderElement } from "@excalidraw/element";
 
 import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
 
@@ -34,6 +34,14 @@ const _renderNewElementScene = ({
     context.scale(appState.zoom.value, appState.zoom.value);
 
     if (newElement && newElement.type !== "selection") {
+      // e.g. when creating arrows and we're still below the arrow drag distance
+      // threshold
+      // (for now we skip render only with elements while we're creating to be
+      // safe)
+      if (isInvisiblySmallElement(newElement)) {
+        return;
+      }
+
       renderElement(
         newElement,
         elementsMap,

+ 0 - 17
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -908,7 +908,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1106,7 +1105,6 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1319,7 +1317,6 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1649,7 +1646,6 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1979,7 +1975,6 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2192,7 +2187,6 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2432,7 +2426,6 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2729,7 +2722,6 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3100,7 +3092,6 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3592,7 +3583,6 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3914,7 +3904,6 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -4236,7 +4225,6 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5520,7 +5508,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -6736,7 +6723,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -7670,7 +7656,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8669,7 +8654,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9659,7 +9643,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,

+ 65 - 13
packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap

@@ -15,7 +15,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
       <div
         class="dropdown-menu-item__text"
       >
-        Click me
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Click me
+        </span>
       </div>
     </button>
     <a
@@ -27,7 +31,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
       <div
         class="dropdown-menu-item__text"
       >
-        Excalidraw blog
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Excalidraw blog
+        </span>
       </div>
     </a>
     <div
@@ -88,7 +96,11 @@ exports[`<Excalidraw/> > <MainMenu/> > should render main menu with host menu it
       <div
         class="dropdown-menu-item__text"
       >
-        Help
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Help
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -138,7 +150,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Open
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Open
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -175,7 +191,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Save to...
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Save to...
+        </span>
       </div>
     </button>
     <button
@@ -231,7 +251,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Export image...
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Export image...
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -280,7 +304,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Find on canvas
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Find on canvas
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -337,7 +365,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Help
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Help
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"
@@ -374,7 +406,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Reset the canvas
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Reset the canvas
+        </span>
       </div>
     </button>
     <div
@@ -419,7 +455,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
         <div
           class="dropdown-menu-item__text"
         >
-          GitHub
+          <span
+            style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+          >
+            GitHub
+          </span>
         </div>
       </a>
       <a
@@ -465,7 +505,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
         <div
           class="dropdown-menu-item__text"
         >
-          Follow us
+          <span
+            style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+          >
+            Follow us
+          </span>
         </div>
       </a>
       <a
@@ -505,7 +549,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
         <div
           class="dropdown-menu-item__text"
         >
-          Discord chat
+          <span
+            style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+          >
+            Discord chat
+          </span>
         </div>
       </a>
     </div>
@@ -542,7 +590,11 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
       <div
         class="dropdown-menu-item__text"
       >
-        Dark mode
+        <span
+          style="text-overflow: ellipsis; overflow: hidden; white-space: nowrap;"
+        >
+          Dark mode
+        </span>
       </div>
       <div
         class="dropdown-menu-item__shortcut"

+ 39 - 74
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap

@@ -34,7 +34,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -545,10 +544,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
             "id4": true,
           },
           "selectedLinearElementId": "id4",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {},
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -646,7 +647,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1027,10 +1027,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
             "id4": true,
           },
           "selectedLinearElementId": "id4",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {},
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -1128,7 +1130,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1491,7 +1492,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1857,7 +1857,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2119,7 +2118,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2417,10 +2415,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
             "id4": true,
           },
           "selectedLinearElementId": "id4",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {},
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -2557,7 +2557,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2818,7 +2817,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3083,7 +3081,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3376,7 +3373,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3661,7 +3657,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3895,7 +3890,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -4151,7 +4145,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -4421,7 +4414,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -4649,7 +4641,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -4877,7 +4868,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5103,7 +5093,6 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5329,7 +5318,6 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5584,7 +5572,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5845,7 +5832,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -6207,7 +6193,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -6580,7 +6565,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -6891,7 +6875,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -7107,9 +7090,11 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
       "delta": Delta {
         "deleted": {
           "selectedLinearElementId": "id0",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -7118,16 +7103,16 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
       "removed": {},
       "updated": {},
     },
-    "id": "id12",
+    "id": "id4",
   },
   {
     "appState": AppStateDelta {
       "delta": Delta {
         "deleted": {
-          "editingLinearElementId": "id0",
+          "selectedLinearElementIsEditing": true,
         },
         "inserted": {
-          "editingLinearElementId": null,
+          "selectedLinearElementIsEditing": false,
         },
       },
     },
@@ -7136,16 +7121,16 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
       "removed": {},
       "updated": {},
     },
-    "id": "id13",
+    "id": "id6",
   },
   {
     "appState": AppStateDelta {
       "delta": Delta {
         "deleted": {
-          "editingLinearElementId": null,
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
-          "editingLinearElementId": "id0",
+          "selectedLinearElementIsEditing": true,
         },
       },
     },
@@ -7154,7 +7139,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
       "removed": {},
       "updated": {},
     },
-    "id": "id14",
+    "id": "id10",
   },
 ]
 `;
@@ -7193,7 +7178,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -7390,7 +7374,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -7741,7 +7724,6 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8092,7 +8074,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8497,7 +8478,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8783,7 +8763,6 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9046,7 +9025,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9310,7 +9288,6 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9541,7 +9518,6 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9837,7 +9813,6 @@ exports[`history > multiplayer undo/redo > should override remotely added points
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -10132,9 +10107,11 @@ exports[`history > multiplayer undo/redo > should override remotely added points
       "delta": Delta {
         "deleted": {
           "selectedLinearElementId": "id0",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -10182,7 +10159,6 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -10406,7 +10382,6 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -10853,7 +10828,6 @@ exports[`history > multiplayer undo/redo > should update history entries after r
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -11112,7 +11086,6 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -11346,7 +11319,6 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -11582,7 +11554,6 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -11984,7 +11955,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": "Couldn't load invalid file",
@@ -12193,7 +12163,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -12402,7 +12371,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -12625,7 +12593,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -12848,7 +12815,6 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -13092,7 +13058,6 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -13328,7 +13293,6 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -13564,7 +13528,6 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -13810,7 +13773,6 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14140,7 +14102,6 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14309,7 +14270,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14592,7 +14552,6 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14854,7 +14813,6 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -15006,7 +14964,6 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -15287,7 +15244,6 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -15448,7 +15404,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -15712,10 +15667,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "id13": true,
           },
           "selectedLinearElementId": "id13",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {},
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -16004,12 +15961,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "id13": true,
           },
           "selectedLinearElementId": "id13",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {
             "id0": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -16146,7 +16105,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -16627,12 +16585,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "id13": true,
           },
           "selectedLinearElementId": "id13",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {
             "id0": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -16777,7 +16737,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -17258,12 +17217,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "id13": true,
           },
           "selectedLinearElementId": "id13",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {
             "id0": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -17408,7 +17369,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -17954,12 +17914,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "id13": true,
           },
           "selectedLinearElementId": "id13",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {
             "id0": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -18067,12 +18029,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "id0": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
         "inserted": {
           "selectedElementIds": {
             "id13": true,
           },
           "selectedLinearElementId": "id13",
+          "selectedLinearElementIsEditing": false,
         },
       },
     },
@@ -18120,7 +18084,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -18678,12 +18641,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "id13": true,
           },
           "selectedLinearElementId": "id13",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {
             "id0": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -18791,12 +18756,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
             "id0": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
         "inserted": {
           "selectedElementIds": {
             "id13": true,
           },
           "selectedLinearElementId": "id13",
+          "selectedLinearElementIsEditing": false,
         },
       },
     },
@@ -18864,7 +18831,6 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -19343,7 +19309,6 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -19853,7 +19818,6 @@ exports[`history > singleplayer undo/redo > should support element creation, del
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -20311,7 +20275,6 @@ exports[`history > singleplayer undo/redo > should support linear element creati
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -20590,9 +20553,11 @@ exports[`history > singleplayer undo/redo > should support linear element creati
       "delta": Delta {
         "deleted": {
           "selectedLinearElementId": "id0",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -20607,10 +20572,10 @@ exports[`history > singleplayer undo/redo > should support linear element creati
     "appState": AppStateDelta {
       "delta": Delta {
         "deleted": {
-          "editingLinearElementId": "id0",
+          "selectedLinearElementIsEditing": true,
         },
         "inserted": {
-          "editingLinearElementId": null,
+          "selectedLinearElementIsEditing": false,
         },
       },
     },
@@ -20678,10 +20643,10 @@ exports[`history > singleplayer undo/redo > should support linear element creati
     "appState": AppStateDelta {
       "delta": Delta {
         "deleted": {
-          "editingLinearElementId": null,
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
-          "editingLinearElementId": "id0",
+          "selectedLinearElementIsEditing": true,
         },
       },
     },

+ 70 - 96
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap

@@ -34,7 +34,6 @@ exports[`given element A and group of elements B and given both are selected whe
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -459,7 +458,6 @@ exports[`given element A and group of elements B and given both are selected whe
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -874,7 +872,6 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": "id28",
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1439,7 +1436,6 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -1645,7 +1641,6 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2028,7 +2023,6 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2272,7 +2266,6 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2451,7 +2444,6 @@ exports[`regression tests > can drag element that covers another element, while
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -2775,7 +2767,6 @@ exports[`regression tests > change the properties of a shape > [end of test] app
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3029,7 +3020,6 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3269,7 +3259,6 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3504,7 +3493,6 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -3761,7 +3749,6 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -4074,7 +4061,6 @@ exports[`regression tests > deleting last but one element in editing group shoul
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -4509,7 +4495,6 @@ exports[`regression tests > deselects group of selected elements on pointer down
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -4791,7 +4776,6 @@ exports[`regression tests > deselects group of selected elements on pointer up w
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5066,7 +5050,6 @@ exports[`regression tests > deselects selected element on pointer down when poin
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5273,7 +5256,6 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5472,7 +5454,6 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": "id11",
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -5864,7 +5845,6 @@ exports[`regression tests > drags selected elements from point inside common bou
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -6160,7 +6140,6 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -6416,12 +6395,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
             "id9": true,
           },
           "selectedLinearElementId": "id9",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {
             "id6": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -6562,12 +6543,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
             "id15": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
         "inserted": {
           "selectedElementIds": {
             "id12": true,
           },
           "selectedLinearElementId": "id12",
+          "selectedLinearElementIsEditing": false,
         },
       },
     },
@@ -6695,9 +6678,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
       "delta": Delta {
         "deleted": {
           "selectedLinearElementId": "id15",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -6716,12 +6701,14 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
             "id22": true,
           },
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
         "inserted": {
           "selectedElementIds": {
             "id15": true,
           },
           "selectedLinearElementId": "id15",
+          "selectedLinearElementIsEditing": false,
         },
       },
     },
@@ -6847,9 +6834,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
       "delta": Delta {
         "deleted": {
           "selectedLinearElementId": "id22",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -6885,9 +6874,11 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
       "delta": Delta {
         "deleted": {
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
         "inserted": {
           "selectedLinearElementId": "id22",
+          "selectedLinearElementIsEditing": false,
         },
       },
     },
@@ -6991,7 +6982,6 @@ exports[`regression tests > given a group of selected elements with an element t
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -7324,7 +7314,6 @@ exports[`regression tests > given a selected element A and a not selected elemen
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -7602,7 +7591,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -7836,7 +7824,6 @@ exports[`regression tests > given selected element A with lower z-index than uns
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8075,7 +8062,6 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8190,7 +8176,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "link": null,
@@ -8203,7 +8189,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] undo st
             "strokeWidth": 2,
             "type": "rectangle",
             "version": 3,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -8254,7 +8240,6 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8369,7 +8354,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "link": null,
@@ -8382,7 +8367,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] undo stac
             "strokeWidth": 2,
             "type": "diamond",
             "version": 3,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -8433,7 +8418,6 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8548,7 +8532,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "link": null,
@@ -8561,7 +8545,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] undo stac
             "strokeWidth": 2,
             "type": "ellipse",
             "version": 3,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -8612,7 +8596,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8676,6 +8659,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
     "endBindingElement": "keep",
     "hoverPointIndex": -1,
     "isDragging": false,
+    "isEditing": false,
     "lastUncommittedPoint": null,
     "pointerDownState": {
       "lastClickedIsEndPoint": false,
@@ -8736,10 +8720,12 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
             "id0": true,
           },
           "selectedLinearElementId": "id0",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {},
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -8758,7 +8744,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "lastCommittedPoint": null,
@@ -8771,8 +8757,8 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
                 0,
               ],
               [
-                10,
-                10,
+                30,
+                30,
               ],
             ],
             "roughness": 1,
@@ -8786,7 +8772,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] undo stack
             "strokeWidth": 2,
             "type": "arrow",
             "version": 4,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -8837,7 +8823,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -8901,6 +8886,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
     "endBindingElement": "keep",
     "hoverPointIndex": -1,
     "isDragging": false,
+    "isEditing": false,
     "lastUncommittedPoint": null,
     "pointerDownState": {
       "lastClickedIsEndPoint": false,
@@ -8961,10 +8947,12 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
             "id0": true,
           },
           "selectedLinearElementId": "id0",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {},
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -8982,7 +8970,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "lastCommittedPoint": null,
@@ -8995,8 +8983,8 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
                 0,
               ],
               [
-                10,
-                10,
+                30,
+                30,
               ],
             ],
             "polygon": false,
@@ -9009,7 +8997,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
             "strokeWidth": 2,
             "type": "line",
             "version": 4,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -9060,7 +9048,6 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9167,12 +9154,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "lastCommittedPoint": [
-              10,
-              10,
+              30,
+              30,
             ],
             "link": null,
             "locked": false,
@@ -9183,12 +9170,12 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
                 0,
               ],
               [
-                10,
-                10,
+                30,
+                30,
               ],
               [
-                10,
-                10,
+                30,
+                30,
               ],
             ],
             "pressures": [
@@ -9204,7 +9191,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] undo sta
             "strokeWidth": 2,
             "type": "freedraw",
             "version": 4,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -9255,7 +9242,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9319,6 +9305,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
     "endBindingElement": "keep",
     "hoverPointIndex": -1,
     "isDragging": false,
+    "isEditing": false,
     "lastUncommittedPoint": null,
     "pointerDownState": {
       "lastClickedIsEndPoint": false,
@@ -9379,10 +9366,12 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
             "id0": true,
           },
           "selectedLinearElementId": "id0",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {},
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -9401,7 +9390,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "lastCommittedPoint": null,
@@ -9414,8 +9403,8 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
                 0,
               ],
               [
-                10,
-                10,
+                30,
+                30,
               ],
             ],
             "roughness": 1,
@@ -9429,7 +9418,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] undo stack
             "strokeWidth": 2,
             "type": "arrow",
             "version": 4,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -9480,7 +9469,6 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9595,7 +9583,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "link": null,
@@ -9608,7 +9596,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] undo stac
             "strokeWidth": 2,
             "type": "diamond",
             "version": 3,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -9659,7 +9647,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9723,6 +9710,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
     "endBindingElement": "keep",
     "hoverPointIndex": -1,
     "isDragging": false,
+    "isEditing": false,
     "lastUncommittedPoint": null,
     "pointerDownState": {
       "lastClickedIsEndPoint": false,
@@ -9783,10 +9771,12 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
             "id0": true,
           },
           "selectedLinearElementId": "id0",
+          "selectedLinearElementIsEditing": false,
         },
         "inserted": {
           "selectedElementIds": {},
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
       },
     },
@@ -9804,7 +9794,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "lastCommittedPoint": null,
@@ -9817,8 +9807,8 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
                 0,
               ],
               [
-                10,
-                10,
+                30,
+                30,
               ],
             ],
             "polygon": false,
@@ -9831,7 +9821,7 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
             "strokeWidth": 2,
             "type": "line",
             "version": 4,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -9882,7 +9872,6 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -9997,7 +9986,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "link": null,
@@ -10010,7 +9999,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] undo stac
             "strokeWidth": 2,
             "type": "ellipse",
             "version": 3,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -10061,7 +10050,6 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -10168,12 +10156,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "lastCommittedPoint": [
-              10,
-              10,
+              30,
+              30,
             ],
             "link": null,
             "locked": false,
@@ -10184,12 +10172,12 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
                 0,
               ],
               [
-                10,
-                10,
+                30,
+                30,
               ],
               [
-                10,
-                10,
+                30,
+                30,
               ],
             ],
             "pressures": [
@@ -10205,7 +10193,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] undo sta
             "strokeWidth": 2,
             "type": "freedraw",
             "version": 4,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -10256,7 +10244,6 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -10371,7 +10358,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
             "fillStyle": "solid",
             "frameId": null,
             "groupIds": [],
-            "height": 10,
+            "height": 30,
             "index": "a0",
             "isDeleted": false,
             "link": null,
@@ -10384,7 +10371,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] undo st
             "strokeWidth": 2,
             "type": "rectangle",
             "version": 3,
-            "width": 10,
+            "width": 30,
             "x": 10,
             "y": 10,
           },
@@ -10435,7 +10422,6 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -10965,7 +10951,6 @@ exports[`regression tests > noop interaction after undo shouldn't create history
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -11244,7 +11229,6 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -11366,7 +11350,6 @@ exports[`regression tests > shift click on selected element should deselect it o
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -11565,7 +11548,6 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -11883,7 +11865,6 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -12311,7 +12292,6 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -12950,7 +12930,6 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -13075,7 +13054,6 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": "id11",
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -13705,7 +13683,6 @@ exports[`regression tests > switches from group of selected elements to another
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14043,7 +14020,6 @@ exports[`regression tests > switches selected element on pointer down > [end of
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14306,7 +14282,6 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14428,7 +14403,6 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14521,9 +14495,11 @@ exports[`regression tests > undo/redo drawing an element > [end of test] redo st
       "delta": Delta {
         "deleted": {
           "selectedLinearElementId": null,
+          "selectedLinearElementIsEditing": null,
         },
         "inserted": {
           "selectedLinearElementId": "id6",
+          "selectedLinearElementIsEditing": false,
         },
       },
     },
@@ -14816,7 +14792,6 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,
@@ -14938,7 +14913,6 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,

+ 20 - 20
packages/excalidraw/tests/history.test.tsx

@@ -1073,7 +1073,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(6);
       expect(API.getRedoStack().length).toBe(0);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement).toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
       expect(h.state.selectedLinearElement).not.toBeNull();
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1090,7 +1090,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(5);
       expect(API.getRedoStack().length).toBe(1);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id);
+      expect(h.state.selectedLinearElement?.isEditing).toBe(true);
       expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1114,7 +1114,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(4);
       expect(API.getRedoStack().length).toBe(2);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id);
+      expect(h.state.selectedLinearElement?.isEditing).toBe(true);
       expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1131,7 +1131,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(3);
       expect(API.getRedoStack().length).toBe(3);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement).toBeNull(); // undo `open editor`
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
       expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1148,7 +1148,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(2);
       expect(API.getRedoStack().length).toBe(4);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement).toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
       expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1165,7 +1165,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(1);
       expect(API.getRedoStack().length).toBe(5);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement).toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
       expect(h.state.selectedLinearElement).toBeNull();
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1181,7 +1181,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(0);
       expect(API.getRedoStack().length).toBe(6);
       expect(API.getSelectedElements().length).toBe(0);
-      expect(h.state.editingLinearElement).toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
       expect(h.state.selectedLinearElement).toBeNull();
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1197,7 +1197,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(1);
       expect(API.getRedoStack().length).toBe(5);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement).toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
       expect(h.state.selectedLinearElement).toBeNull();
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1213,7 +1213,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(2);
       expect(API.getRedoStack().length).toBe(4);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement).toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
       expect(h.state.selectedLinearElement).toBeNull(); // undo `actionFinalize`
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1230,7 +1230,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(3);
       expect(API.getRedoStack().length).toBe(3);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement).toBeNull(); // undo `open editor`
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false); // undo `open editor`
       expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1247,7 +1247,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(4);
       expect(API.getRedoStack().length).toBe(2);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id);
+      expect(h.state.selectedLinearElement?.isEditing).toBe(true);
       expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1264,7 +1264,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(5);
       expect(API.getRedoStack().length).toBe(1);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement?.elementId).toBe(h.elements[0].id);
+      expect(h.state.selectedLinearElement?.isEditing).toBe(true);
       expect(h.state.selectedLinearElement?.elementId).toBe(h.elements[0].id);
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -1281,7 +1281,7 @@ describe("history", () => {
       expect(API.getUndoStack().length).toBe(6);
       expect(API.getRedoStack().length).toBe(0);
       expect(assertSelectedElements(h.elements[0]));
-      expect(h.state.editingLinearElement).toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
       expect(h.state.selectedLinearElement).not.toBeNull();
       expect(h.elements).toEqual([
         expect.objectContaining({
@@ -3029,8 +3029,8 @@ describe("history", () => {
 
       expect(API.getUndoStack().length).toBe(4);
       expect(API.getRedoStack().length).toBe(0);
-      expect(h.state.editingLinearElement).toBeNull();
       expect(h.state.selectedLinearElement).not.toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing).toBe(false);
 
       // Simulate remote update
       API.updateScene({
@@ -3043,16 +3043,16 @@ describe("history", () => {
       });
 
       Keyboard.undo();
-      expect(API.getUndoStack().length).toBe(1);
-      expect(API.getRedoStack().length).toBe(3);
-      expect(h.state.editingLinearElement).toBeNull();
-      expect(h.state.selectedLinearElement).toBeNull();
+      expect(API.getUndoStack().length).toBe(3);
+      expect(API.getRedoStack().length).toBe(1);
+      expect(h.state.selectedLinearElement).not.toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing).toBe(true);
 
       Keyboard.redo();
       expect(API.getUndoStack().length).toBe(4);
       expect(API.getRedoStack().length).toBe(0);
-      expect(h.state.editingLinearElement).toBeNull();
-      expect(h.state.selectedLinearElement).toBeNull();
+      expect(h.state.selectedLinearElement).not.toBeNull();
+      expect(h.state.selectedLinearElement?.isEditing ?? false).toBe(false);
     });
 
     it("should iterate through the history when z-index changes do not produce visible change and we synced changed indices", async () => {

+ 1 - 1
packages/excalidraw/tests/regressionTests.test.tsx

@@ -150,7 +150,7 @@ describe("regression tests", () => {
         expect(h.state.activeTool.type).toBe(shape);
 
         mouse.down(10, 10);
-        mouse.up(10, 10);
+        mouse.up(30, 30);
 
         if (shouldSelect) {
           expect(API.getSelectedElement().type).toBe(shape);

+ 1 - 5
packages/excalidraw/types.ts

@@ -213,7 +213,6 @@ export type InteractiveCanvasAppState = Readonly<
   _CommonCanvasAppState & {
     // renderInteractiveScene
     activeEmbeddable: AppState["activeEmbeddable"];
-    editingLinearElement: AppState["editingLinearElement"];
     selectionElement: AppState["selectionElement"];
     selectedGroupIds: AppState["selectedGroupIds"];
     selectedLinearElement: AppState["selectedLinearElement"];
@@ -249,10 +248,8 @@ export type ObservedElementsAppState = {
   editingGroupId: AppState["editingGroupId"];
   selectedElementIds: AppState["selectedElementIds"];
   selectedGroupIds: AppState["selectedGroupIds"];
-  // Avoiding storing whole instance, as it could lead into state incosistencies, empty undos/redos and etc.
-  editingLinearElementId: LinearElementEditor["elementId"] | null;
-  // Right now it's coupled to `editingLinearElement`, ideally it should not be really needed as we already have selectedElementIds & editingLinearElementId
   selectedLinearElementId: LinearElementEditor["elementId"] | null;
+  selectedLinearElementIsEditing: boolean | null;
   croppingElementId: AppState["croppingElementId"];
   lockedMultiSelections: AppState["lockedMultiSelections"];
   activeLockedId: AppState["activeLockedId"];
@@ -307,7 +304,6 @@ export interface AppState {
    * set when a new text is created or when an existing text is being edited
    */
   editingTextElement: NonDeletedExcalidrawElement | null;
-  editingLinearElement: LinearElementEditor | null;
   activeTool: {
     /**
      * indicates a previous tool we should revert back to if we deselect the

+ 2 - 2
packages/excalidraw/wysiwyg/textWysiwyg.test.tsx

@@ -704,7 +704,7 @@ describe("textWysiwyg", () => {
         rectangle.x + rectangle.width / 2,
         rectangle.y + rectangle.height / 2,
       );
-      expect(h.elements.length).toBe(3);
+      expect(h.elements.length).toBe(2);
 
       text = h.elements[1] as ExcalidrawTextElementWithContainer;
       expect(text.type).toBe("text");
@@ -1198,7 +1198,7 @@ describe("textWysiwyg", () => {
       updateTextEditor(editor, "   ");
       Keyboard.exitTextEditor(editor);
       expect(rectangle.boundElements).toStrictEqual([]);
-      expect(h.elements[1].isDeleted).toBe(true);
+      expect(h.elements[1]).toBeUndefined();
     });
 
     it("should restore original container height and clear cache once text is unbind", async () => {

+ 0 - 1
packages/utils/tests/__snapshots__/export.test.ts.snap

@@ -34,7 +34,6 @@ exports[`exportToSvg > with default arguments 1`] = `
   "defaultSidebarDockedPreference": false,
   "editingFrame": null,
   "editingGroupId": null,
-  "editingLinearElement": null,
   "editingTextElement": null,
   "elementsToHighlight": null,
   "errorMessage": null,