Browse Source

fix: stats state leaking & race conds (#8177)

David Luzar 1 year ago
parent
commit
744b3e5d09

+ 53 - 40
packages/excalidraw/components/Stats/Angle.tsx

@@ -1,67 +1,77 @@
 import { mutateElement } from "../../element/mutateElement";
 import { mutateElement } from "../../element/mutateElement";
 import { getBoundTextElement } from "../../element/textElement";
 import { getBoundTextElement } from "../../element/textElement";
 import { isArrowElement } from "../../element/typeChecks";
 import { isArrowElement } from "../../element/typeChecks";
-import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import type { ExcalidrawElement } from "../../element/types";
 import { degreeToRadian, radianToDegree } from "../../math";
 import { degreeToRadian, radianToDegree } from "../../math";
 import { angleIcon } from "../icons";
 import { angleIcon } from "../icons";
 import DragInput from "./DragInput";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, isPropertyEditable } from "./utils";
 import { getStepSizedValue, isPropertyEditable } from "./utils";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
 
 
 interface AngleProps {
 interface AngleProps {
   element: ExcalidrawElement;
   element: ExcalidrawElement;
-  elementsMap: ElementsMap;
+  scene: Scene;
+  appState: AppState;
+  property: "angle";
 }
 }
 
 
 const STEP_SIZE = 15;
 const STEP_SIZE = 15;
 
 
-const Angle = ({ element, elementsMap }: AngleProps) => {
-  const handleDegreeChange: DragInputCallbackType = ({
-    accumulatedChange,
-    originalElements,
-    shouldChangeByStepSize,
-    nextValue,
-  }) => {
-    const origElement = originalElements[0];
-    if (origElement) {
-      if (nextValue !== undefined) {
-        const nextAngle = degreeToRadian(nextValue);
-        mutateElement(element, {
-          angle: nextAngle,
-        });
-
-        const boundTextElement = getBoundTextElement(element, elementsMap);
-        if (boundTextElement && !isArrowElement(element)) {
-          mutateElement(boundTextElement, { angle: nextAngle });
-        }
+const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
+  accumulatedChange,
+  originalElements,
+  shouldChangeByStepSize,
+  nextValue,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const origElement = originalElements[0];
+  if (origElement) {
+    const latestElement = elementsMap.get(origElement.id);
+    if (!latestElement) {
+      return;
+    }
+    if (nextValue !== undefined) {
+      const nextAngle = degreeToRadian(nextValue);
+      mutateElement(latestElement, {
+        angle: nextAngle,
+      });
 
 
-        return;
+      const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+      if (boundTextElement && !isArrowElement(latestElement)) {
+        mutateElement(boundTextElement, { angle: nextAngle });
       }
       }
 
 
-      const originalAngleInDegrees =
-        Math.round(radianToDegree(origElement.angle) * 100) / 100;
-      const changeInDegrees = Math.round(accumulatedChange);
-      let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
-      if (shouldChangeByStepSize) {
-        nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
-      }
+      return;
+    }
 
 
-      nextAngleInDegrees =
-        nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
+    const originalAngleInDegrees =
+      Math.round(radianToDegree(origElement.angle) * 100) / 100;
+    const changeInDegrees = Math.round(accumulatedChange);
+    let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
+    if (shouldChangeByStepSize) {
+      nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
+    }
 
 
-      const nextAngle = degreeToRadian(nextAngleInDegrees);
+    nextAngleInDegrees =
+      nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
 
 
-      mutateElement(element, {
-        angle: nextAngle,
-      });
+    const nextAngle = degreeToRadian(nextAngleInDegrees);
 
 
-      const boundTextElement = getBoundTextElement(element, elementsMap);
-      if (boundTextElement && !isArrowElement(element)) {
-        mutateElement(boundTextElement, { angle: nextAngle });
-      }
+    mutateElement(latestElement, {
+      angle: nextAngle,
+    });
+
+    const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+    if (boundTextElement && !isArrowElement(latestElement)) {
+      mutateElement(boundTextElement, { angle: nextAngle });
     }
     }
-  };
+  }
+};
 
 
+const Angle = ({ element, scene, appState, property }: AngleProps) => {
   return (
   return (
     <DragInput
     <DragInput
       label="A"
       label="A"
@@ -70,6 +80,9 @@ const Angle = ({ element, elementsMap }: AngleProps) => {
       elements={[element]}
       elements={[element]}
       dragInputCallback={handleDegreeChange}
       dragInputCallback={handleDegreeChange}
       editable={isPropertyEditable(element, "angle")}
       editable={isPropertyEditable(element, "angle")}
+      scene={scene}
+      appState={appState}
+      property={property}
     />
     />
   );
   );
 };
 };

+ 90 - 82
packages/excalidraw/components/Stats/Dimension.tsx

@@ -1,13 +1,16 @@
-import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import type { ExcalidrawElement } from "../../element/types";
 import DragInput from "./DragInput";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
 import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
 
 
 interface DimensionDragInputProps {
 interface DimensionDragInputProps {
   property: "width" | "height";
   property: "width" | "height";
   element: ExcalidrawElement;
   element: ExcalidrawElement;
-  elementsMap: ElementsMap;
+  scene: Scene;
+  appState: AppState;
 }
 }
 
 
 const STEP_SIZE = 10;
 const STEP_SIZE = 10;
@@ -15,99 +18,101 @@ const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
   return element.type === "image";
   return element.type === "image";
 };
 };
 
 
-const DimensionDragInput = ({
+const handleDimensionChange: DragInputCallbackType<
+  DimensionDragInputProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  originalElementsMap,
+  shouldKeepAspectRatio,
+  shouldChangeByStepSize,
+  nextValue,
   property,
   property,
-  element,
-  elementsMap,
-}: DimensionDragInputProps) => {
-  const handleDimensionChange: DragInputCallbackType = ({
-    accumulatedChange,
-    originalElements,
-    originalElementsMap,
-    shouldKeepAspectRatio,
-    shouldChangeByStepSize,
-    nextValue,
-  }) => {
-    const origElement = originalElements[0];
-    if (origElement) {
-      const keepAspectRatio =
-        shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
-      const aspectRatio = origElement.width / origElement.height;
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const origElement = originalElements[0];
+  if (origElement) {
+    const keepAspectRatio =
+      shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
+    const aspectRatio = origElement.width / origElement.height;
 
 
-      if (nextValue !== undefined) {
-        const nextWidth = Math.max(
-          property === "width"
-            ? nextValue
-            : keepAspectRatio
-            ? nextValue * aspectRatio
-            : origElement.width,
-          MIN_WIDTH_OR_HEIGHT,
-        );
-        const nextHeight = Math.max(
-          property === "height"
-            ? nextValue
-            : keepAspectRatio
-            ? nextValue / aspectRatio
-            : origElement.height,
-          MIN_WIDTH_OR_HEIGHT,
-        );
+    if (nextValue !== undefined) {
+      const nextWidth = Math.max(
+        property === "width"
+          ? nextValue
+          : keepAspectRatio
+          ? nextValue * aspectRatio
+          : origElement.width,
+        MIN_WIDTH_OR_HEIGHT,
+      );
+      const nextHeight = Math.max(
+        property === "height"
+          ? nextValue
+          : keepAspectRatio
+          ? nextValue / aspectRatio
+          : origElement.height,
+        MIN_WIDTH_OR_HEIGHT,
+      );
 
 
-        resizeElement(
-          nextWidth,
-          nextHeight,
-          keepAspectRatio,
-          element,
-          origElement,
-          elementsMap,
-          originalElementsMap,
-        );
+      resizeElement(
+        nextWidth,
+        nextHeight,
+        keepAspectRatio,
+        origElement,
+        elementsMap,
+      );
 
 
-        return;
-      }
-      const changeInWidth = property === "width" ? accumulatedChange : 0;
-      const changeInHeight = property === "height" ? accumulatedChange : 0;
+      return;
+    }
+    const changeInWidth = property === "width" ? accumulatedChange : 0;
+    const changeInHeight = property === "height" ? accumulatedChange : 0;
 
 
-      let nextWidth = Math.max(0, origElement.width + changeInWidth);
-      if (property === "width") {
-        if (shouldChangeByStepSize) {
-          nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
-        } else {
-          nextWidth = Math.round(nextWidth);
-        }
+    let nextWidth = Math.max(0, origElement.width + changeInWidth);
+    if (property === "width") {
+      if (shouldChangeByStepSize) {
+        nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
+      } else {
+        nextWidth = Math.round(nextWidth);
       }
       }
+    }
 
 
-      let nextHeight = Math.max(0, origElement.height + changeInHeight);
-      if (property === "height") {
-        if (shouldChangeByStepSize) {
-          nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
-        } else {
-          nextHeight = Math.round(nextHeight);
-        }
+    let nextHeight = Math.max(0, origElement.height + changeInHeight);
+    if (property === "height") {
+      if (shouldChangeByStepSize) {
+        nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
+      } else {
+        nextHeight = Math.round(nextHeight);
       }
       }
+    }
 
 
-      if (keepAspectRatio) {
-        if (property === "width") {
-          nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
-        } else {
-          nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
-        }
+    if (keepAspectRatio) {
+      if (property === "width") {
+        nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
+      } else {
+        nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
       }
       }
+    }
 
 
-      nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
-      nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
+    nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+    nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
 
 
-      resizeElement(
-        nextWidth,
-        nextHeight,
-        keepAspectRatio,
-        element,
-        origElement,
-        elementsMap,
-        originalElementsMap,
-      );
-    }
-  };
+    resizeElement(
+      nextWidth,
+      nextHeight,
+      keepAspectRatio,
+      origElement,
+      elementsMap,
+    );
+  }
+};
 
 
+const DimensionDragInput = ({
+  property,
+  element,
+  scene,
+  appState,
+}: DimensionDragInputProps) => {
   const value =
   const value =
     Math.round((property === "width" ? element.width : element.height) * 100) /
     Math.round((property === "width" ? element.width : element.height) * 100) /
     100;
     100;
@@ -119,6 +124,9 @@ const DimensionDragInput = ({
       dragInputCallback={handleDimensionChange}
       dragInputCallback={handleDimensionChange}
       value={value}
       value={value}
       editable={isPropertyEditable(element, property)}
       editable={isPropertyEditable(element, property)}
+      scene={scene}
+      appState={appState}
+      property={property}
     />
     />
   );
   );
 };
 };

+ 86 - 26
packages/excalidraw/components/Stats/DragInput.tsx

@@ -3,23 +3,19 @@ import { EVENT } from "../../constants";
 import { KEYS } from "../../keys";
 import { KEYS } from "../../keys";
 import type { ElementsMap, ExcalidrawElement } from "../../element/types";
 import type { ElementsMap, ExcalidrawElement } from "../../element/types";
 import { deepCopyElement } from "../../element/newElement";
 import { deepCopyElement } from "../../element/newElement";
-
-import "./DragInput.scss";
 import clsx from "clsx";
 import clsx from "clsx";
 import { useApp } from "../App";
 import { useApp } from "../App";
 import { InlineIcon } from "../InlineIcon";
 import { InlineIcon } from "../InlineIcon";
+import type { StatsInputProperty } from "./utils";
 import { SMALLEST_DELTA } from "./utils";
 import { SMALLEST_DELTA } from "./utils";
 import { StoreAction } from "../../store";
 import { StoreAction } from "../../store";
+import type Scene from "../../scene/Scene";
 
 
-export type DragInputCallbackType = ({
-  accumulatedChange,
-  instantChange,
-  originalElements,
-  originalElementsMap,
-  shouldKeepAspectRatio,
-  shouldChangeByStepSize,
-  nextValue,
-}: {
+import "./DragInput.scss";
+import type { AppState } from "../../types";
+import { cloneJSON } from "../../utils";
+
+export type DragInputCallbackType<T extends StatsInputProperty> = (props: {
   accumulatedChange: number;
   accumulatedChange: number;
   instantChange: number;
   instantChange: number;
   originalElements: readonly ExcalidrawElement[];
   originalElements: readonly ExcalidrawElement[];
@@ -27,19 +23,25 @@ export type DragInputCallbackType = ({
   shouldKeepAspectRatio: boolean;
   shouldKeepAspectRatio: boolean;
   shouldChangeByStepSize: boolean;
   shouldChangeByStepSize: boolean;
   nextValue?: number;
   nextValue?: number;
+  property: T;
+  scene: Scene;
+  originalAppState: AppState;
 }) => void;
 }) => void;
 
 
-interface StatsDragInputProps {
+interface StatsDragInputProps<T extends StatsInputProperty> {
   label: string | React.ReactNode;
   label: string | React.ReactNode;
   icon?: React.ReactNode;
   icon?: React.ReactNode;
   value: number | "Mixed";
   value: number | "Mixed";
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
   editable?: boolean;
   editable?: boolean;
   shouldKeepAspectRatio?: boolean;
   shouldKeepAspectRatio?: boolean;
-  dragInputCallback: DragInputCallbackType;
+  dragInputCallback: DragInputCallbackType<T>;
+  property: T;
+  scene: Scene;
+  appState: AppState;
 }
 }
 
 
-const StatsDragInput = ({
+const StatsDragInput = <T extends StatsInputProperty>({
   label,
   label,
   icon,
   icon,
   dragInputCallback,
   dragInputCallback,
@@ -47,19 +49,48 @@ const StatsDragInput = ({
   elements,
   elements,
   editable = true,
   editable = true,
   shouldKeepAspectRatio,
   shouldKeepAspectRatio,
-}: StatsDragInputProps) => {
+  property,
+  scene,
+  appState,
+}: StatsDragInputProps<T>) => {
   const app = useApp();
   const app = useApp();
   const inputRef = useRef<HTMLInputElement>(null);
   const inputRef = useRef<HTMLInputElement>(null);
   const labelRef = useRef<HTMLDivElement>(null);
   const labelRef = useRef<HTMLDivElement>(null);
 
 
   const [inputValue, setInputValue] = useState(value.toString());
   const [inputValue, setInputValue] = useState(value.toString());
 
 
+  const stateRef = useRef<{
+    originalAppState: AppState;
+    originalElements: readonly ExcalidrawElement[];
+    lastUpdatedValue: string;
+    updatePending: boolean;
+  }>(null!);
+  if (!stateRef.current) {
+    stateRef.current = {
+      originalAppState: cloneJSON(appState),
+      originalElements: elements,
+      lastUpdatedValue: inputValue,
+      updatePending: false,
+    };
+  }
+
   useEffect(() => {
   useEffect(() => {
-    setInputValue(value.toString());
-  }, [value, elements]);
+    const inputValue = value.toString();
+    setInputValue(inputValue);
+    stateRef.current.lastUpdatedValue = inputValue;
+  }, [value]);
+
+  const handleInputValue = (
+    updatedValue: string,
+    elements: readonly ExcalidrawElement[],
+    appState: AppState,
+  ) => {
+    if (!stateRef.current.updatePending) {
+      return false;
+    }
+    stateRef.current.updatePending = false;
 
 
-  const handleInputValue = (v: string) => {
-    const parsed = Number(v);
+    const parsed = Number(updatedValue);
     if (isNaN(parsed)) {
     if (isNaN(parsed)) {
       setInputValue(value.toString());
       setInputValue(value.toString());
       return;
       return;
@@ -74,6 +105,7 @@ const StatsDragInput = ({
     //    than the smallest delta allowed, which is 0.01
     //    than the smallest delta allowed, which is 0.01
     // reason: idempotent to avoid unnecessary
     // reason: idempotent to avoid unnecessary
     if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
     if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
+      stateRef.current.lastUpdatedValue = updatedValue;
       dragInputCallback({
       dragInputCallback({
         accumulatedChange: 0,
         accumulatedChange: 0,
         instantChange: 0,
         instantChange: 0,
@@ -82,6 +114,9 @@ const StatsDragInput = ({
         shouldKeepAspectRatio: shouldKeepAspectRatio!!,
         shouldKeepAspectRatio: shouldKeepAspectRatio!!,
         shouldChangeByStepSize: false,
         shouldChangeByStepSize: false,
         nextValue: rounded,
         nextValue: rounded,
+        property,
+        scene,
+        originalAppState: appState,
       });
       });
       app.syncActionResult({ storeAction: StoreAction.CAPTURE });
       app.syncActionResult({ storeAction: StoreAction.CAPTURE });
     }
     }
@@ -97,12 +132,28 @@ const StatsDragInput = ({
     return () => {
     return () => {
       const nextValue = input?.value;
       const nextValue = input?.value;
       if (nextValue) {
       if (nextValue) {
-        handleInputValueRef.current(nextValue);
+        handleInputValueRef.current(
+          nextValue,
+          stateRef.current.originalElements,
+          stateRef.current.originalAppState,
+        );
       }
       }
     };
     };
-  }, []);
+  }, [
+    // we need to track change of `editable` state as mount/unmount
+    // because react doesn't trigger `blur` when a an input is blurred due
+    // to being disabled (https://github.com/facebook/react/issues/9142).
+    // As such, if we keep rendering disabled inputs, then change in selection
+    // to an element that has a given property as non-editable would not trigger
+    // blur/unmount and wouldn't update the value.
+    editable,
+  ]);
 
 
-  return editable ? (
+  if (!editable) {
+    return null;
+  }
+
+  return (
     <div
     <div
       className={clsx("drag-input-container", !editable && "disabled")}
       className={clsx("drag-input-container", !editable && "disabled")}
       data-testid={label}
       data-testid={label}
@@ -125,6 +176,7 @@ const StatsDragInput = ({
             let originalElements: ExcalidrawElement[] | null = null;
             let originalElements: ExcalidrawElement[] | null = null;
             let originalElementsMap: Map<string, ExcalidrawElement> | null =
             let originalElementsMap: Map<string, ExcalidrawElement> | null =
               null;
               null;
+            const originalAppState: AppState = cloneJSON(appState);
 
 
             let accumulatedChange: number | null = null;
             let accumulatedChange: number | null = null;
 
 
@@ -165,6 +217,9 @@ const StatsDragInput = ({
                   originalElementsMap,
                   originalElementsMap,
                   shouldKeepAspectRatio: shouldKeepAspectRatio!!,
                   shouldKeepAspectRatio: shouldKeepAspectRatio!!,
                   shouldChangeByStepSize: event.shiftKey,
                   shouldChangeByStepSize: event.shiftKey,
+                  property,
+                  scene,
+                  originalAppState,
                 });
                 });
               }
               }
 
 
@@ -216,7 +271,7 @@ const StatsDragInput = ({
               eventTarget instanceof HTMLInputElement &&
               eventTarget instanceof HTMLInputElement &&
               event.key === KEYS.ENTER
               event.key === KEYS.ENTER
             ) {
             ) {
-              handleInputValue(eventTarget.value);
+              handleInputValue(eventTarget.value, elements, appState);
               app.focusContainer();
               app.focusContainer();
             }
             }
           }
           }
@@ -224,23 +279,28 @@ const StatsDragInput = ({
         ref={inputRef}
         ref={inputRef}
         value={inputValue}
         value={inputValue}
         onChange={(event) => {
         onChange={(event) => {
+          stateRef.current.updatePending = true;
           setInputValue(event.target.value);
           setInputValue(event.target.value);
         }}
         }}
         onFocus={(event) => {
         onFocus={(event) => {
           event.target.select();
           event.target.select();
+          stateRef.current.originalElements = elements;
+          stateRef.current.originalAppState = cloneJSON(appState);
         }}
         }}
         onBlur={(event) => {
         onBlur={(event) => {
           if (!inputValue) {
           if (!inputValue) {
             setInputValue(value.toString());
             setInputValue(value.toString());
           } else if (editable) {
           } else if (editable) {
-            handleInputValue(event.target.value);
+            handleInputValue(
+              event.target.value,
+              stateRef.current.originalElements,
+              stateRef.current.originalAppState,
+            );
           }
           }
         }}
         }}
         disabled={!editable}
         disabled={!editable}
       />
       />
     </div>
     </div>
-  ) : (
-    <></>
   );
   );
 };
 };
 
 

+ 61 - 44
packages/excalidraw/components/Stats/FontSize.tsx

@@ -1,66 +1,80 @@
-import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
+import type { ExcalidrawTextElement } from "../../element/types";
 import { refreshTextDimensions } from "../../element/newElement";
 import { refreshTextDimensions } from "../../element/newElement";
 import StatsDragInput from "./DragInput";
 import StatsDragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { mutateElement } from "../../element/mutateElement";
 import { mutateElement } from "../../element/mutateElement";
 import { getStepSizedValue } from "./utils";
 import { getStepSizedValue } from "./utils";
 import { fontSizeIcon } from "../icons";
 import { fontSizeIcon } from "../icons";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import { isTextElement } from "../../element";
 
 
 interface FontSizeProps {
 interface FontSizeProps {
   element: ExcalidrawTextElement;
   element: ExcalidrawTextElement;
-  elementsMap: ElementsMap;
+  scene: Scene;
+  appState: AppState;
+  property: "fontSize";
 }
 }
 
 
 const MIN_FONT_SIZE = 4;
 const MIN_FONT_SIZE = 4;
 const STEP_SIZE = 4;
 const STEP_SIZE = 4;
 
 
-const FontSize = ({ element, elementsMap }: FontSizeProps) => {
-  const handleFontSizeChange: DragInputCallbackType = ({
-    accumulatedChange,
-    originalElements,
-    shouldChangeByStepSize,
-    nextValue,
-  }) => {
-    const origElement = originalElements[0];
-    if (origElement) {
-      if (nextValue !== undefined) {
-        const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
+const handleFontSizeChange: DragInputCallbackType<
+  FontSizeProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  shouldChangeByStepSize,
+  nextValue,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
 
 
-        const newElement = {
-          ...element,
-          fontSize: nextFontSize,
-        };
-        const updates = refreshTextDimensions(newElement, null, elementsMap);
-        mutateElement(element, {
-          ...updates,
-          fontSize: nextFontSize,
-        });
-        return;
-      }
+  const origElement = originalElements[0];
+  if (origElement) {
+    const latestElement = elementsMap.get(origElement.id);
+    if (!latestElement || !isTextElement(latestElement)) {
+      return;
+    }
+    if (nextValue !== undefined) {
+      const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
 
 
-      if (origElement.type === "text") {
-        const originalFontSize = Math.round(origElement.fontSize);
-        const changeInFontSize = Math.round(accumulatedChange);
-        let nextFontSize = Math.max(
-          originalFontSize + changeInFontSize,
-          MIN_FONT_SIZE,
-        );
-        if (shouldChangeByStepSize) {
-          nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
-        }
-        const newElement = {
-          ...element,
-          fontSize: nextFontSize,
-        };
-        const updates = refreshTextDimensions(newElement, null, elementsMap);
-        mutateElement(element, {
-          ...updates,
-          fontSize: nextFontSize,
-        });
+      const newElement = {
+        ...latestElement,
+        fontSize: nextFontSize,
+      };
+      const updates = refreshTextDimensions(newElement, null, elementsMap);
+      mutateElement(latestElement, {
+        ...updates,
+        fontSize: nextFontSize,
+      });
+      return;
+    }
+
+    if (origElement.type === "text") {
+      const originalFontSize = Math.round(origElement.fontSize);
+      const changeInFontSize = Math.round(accumulatedChange);
+      let nextFontSize = Math.max(
+        originalFontSize + changeInFontSize,
+        MIN_FONT_SIZE,
+      );
+      if (shouldChangeByStepSize) {
+        nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
       }
       }
+      const newElement = {
+        ...latestElement,
+        fontSize: nextFontSize,
+      };
+      const updates = refreshTextDimensions(newElement, null, elementsMap);
+      mutateElement(latestElement, {
+        ...updates,
+        fontSize: nextFontSize,
+      });
     }
     }
-  };
+  }
+};
 
 
+const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
   return (
   return (
     <StatsDragInput
     <StatsDragInput
       label="F"
       label="F"
@@ -68,6 +82,9 @@ const FontSize = ({ element, elementsMap }: FontSizeProps) => {
       elements={[element]}
       elements={[element]}
       dragInputCallback={handleFontSizeChange}
       dragInputCallback={handleFontSizeChange}
       icon={fontSizeIcon}
       icon={fontSizeIcon}
+      appState={appState}
+      scene={scene}
+      property={property}
     />
     />
   );
   );
 };
 };

+ 77 - 56
packages/excalidraw/components/Stats/MultiAngle.tsx

@@ -1,7 +1,7 @@
 import { mutateElement } from "../../element/mutateElement";
 import { mutateElement } from "../../element/mutateElement";
 import { getBoundTextElement } from "../../element/textElement";
 import { getBoundTextElement } from "../../element/textElement";
 import { isArrowElement } from "../../element/typeChecks";
 import { isArrowElement } from "../../element/typeChecks";
-import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import type { ExcalidrawElement } from "../../element/types";
 import { isInGroup } from "../../groups";
 import { isInGroup } from "../../groups";
 import { degreeToRadian, radianToDegree } from "../../math";
 import { degreeToRadian, radianToDegree } from "../../math";
 import type Scene from "../../scene/Scene";
 import type Scene from "../../scene/Scene";
@@ -9,84 +9,102 @@ import { angleIcon } from "../icons";
 import DragInput from "./DragInput";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, isPropertyEditable } from "./utils";
 import { getStepSizedValue, isPropertyEditable } from "./utils";
+import type { AppState } from "../../types";
 
 
 interface MultiAngleProps {
 interface MultiAngleProps {
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
-  elementsMap: ElementsMap;
   scene: Scene;
   scene: Scene;
+  appState: AppState;
+  property: "angle";
 }
 }
 
 
 const STEP_SIZE = 15;
 const STEP_SIZE = 15;
 
 
-const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
-  const handleDegreeChange: DragInputCallbackType = ({
-    accumulatedChange,
-    originalElements,
-    shouldChangeByStepSize,
-    nextValue,
-  }) => {
-    const editableLatestIndividualElements = elements.filter(
-      (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
-    );
-    const editableOriginalIndividualElements = originalElements.filter(
-      (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
-    );
-
-    if (nextValue !== undefined) {
-      const nextAngle = degreeToRadian(nextValue);
-
-      for (const element of editableLatestIndividualElements) {
-        mutateElement(
-          element,
-          {
-            angle: nextAngle,
-          },
-          false,
-        );
-
-        const boundTextElement = getBoundTextElement(element, elementsMap);
-        if (boundTextElement && !isArrowElement(element)) {
-          mutateElement(boundTextElement, { angle: nextAngle }, false);
-        }
-      }
+const handleDegreeChange: DragInputCallbackType<
+  MultiAngleProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  shouldChangeByStepSize,
+  nextValue,
+  property,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const editableLatestIndividualElements = originalElements
+    .map((el) => elementsMap.get(el.id))
+    .filter((el) => el && !isInGroup(el) && isPropertyEditable(el, property));
+  const editableOriginalIndividualElements = originalElements.filter(
+    (el) => !isInGroup(el) && isPropertyEditable(el, property),
+  );
 
 
-      scene.triggerUpdate();
+  if (nextValue !== undefined) {
+    const nextAngle = degreeToRadian(nextValue);
 
 
-      return;
-    }
-
-    for (let i = 0; i < editableLatestIndividualElements.length; i++) {
-      const latestElement = editableLatestIndividualElements[i];
-      const originalElement = editableOriginalIndividualElements[i];
-      const originalAngleInDegrees =
-        Math.round(radianToDegree(originalElement.angle) * 100) / 100;
-      const changeInDegrees = Math.round(accumulatedChange);
-      let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
-      if (shouldChangeByStepSize) {
-        nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
+    for (const element of editableLatestIndividualElements) {
+      if (!element) {
+        continue;
       }
       }
-
-      nextAngleInDegrees =
-        nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
-
-      const nextAngle = degreeToRadian(nextAngleInDegrees);
-
       mutateElement(
       mutateElement(
-        latestElement,
+        element,
         {
         {
           angle: nextAngle,
           angle: nextAngle,
         },
         },
         false,
         false,
       );
       );
 
 
-      const boundTextElement = getBoundTextElement(latestElement, elementsMap);
-      if (boundTextElement && !isArrowElement(latestElement)) {
+      const boundTextElement = getBoundTextElement(element, elementsMap);
+      if (boundTextElement && !isArrowElement(element)) {
         mutateElement(boundTextElement, { angle: nextAngle }, false);
         mutateElement(boundTextElement, { angle: nextAngle }, false);
       }
       }
     }
     }
+
     scene.triggerUpdate();
     scene.triggerUpdate();
-  };
 
 
+    return;
+  }
+
+  for (let i = 0; i < editableLatestIndividualElements.length; i++) {
+    const latestElement = editableLatestIndividualElements[i];
+    if (!latestElement) {
+      continue;
+    }
+    const originalElement = editableOriginalIndividualElements[i];
+    const originalAngleInDegrees =
+      Math.round(radianToDegree(originalElement.angle) * 100) / 100;
+    const changeInDegrees = Math.round(accumulatedChange);
+    let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
+    if (shouldChangeByStepSize) {
+      nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
+    }
+
+    nextAngleInDegrees =
+      nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
+
+    const nextAngle = degreeToRadian(nextAngleInDegrees);
+
+    mutateElement(
+      latestElement,
+      {
+        angle: nextAngle,
+      },
+      false,
+    );
+
+    const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+    if (boundTextElement && !isArrowElement(latestElement)) {
+      mutateElement(boundTextElement, { angle: nextAngle }, false);
+    }
+  }
+  scene.triggerUpdate();
+};
+
+const MultiAngle = ({
+  elements,
+  scene,
+  appState,
+  property,
+}: MultiAngleProps) => {
   const editableLatestIndividualElements = elements.filter(
   const editableLatestIndividualElements = elements.filter(
     (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
     (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
   );
   );
@@ -107,6 +125,9 @@ const MultiAngle = ({ elements, elementsMap, scene }: MultiAngleProps) => {
       elements={elements}
       elements={elements}
       dragInputCallback={handleDegreeChange}
       dragInputCallback={handleDegreeChange}
       editable={editable}
       editable={editable}
+      appState={appState}
+      scene={scene}
+      property={property}
     />
     />
   );
   );
 };
 };

+ 165 - 164
packages/excalidraw/components/Stats/MultiDimension.tsx

@@ -9,10 +9,10 @@ import {
 } from "../../element/textElement";
 } from "../../element/textElement";
 import type { ElementsMap, ExcalidrawElement } from "../../element/types";
 import type { ElementsMap, ExcalidrawElement } from "../../element/types";
 import type Scene from "../../scene/Scene";
 import type Scene from "../../scene/Scene";
-import type { Point } from "../../types";
+import type { AppState, Point } from "../../types";
 import DragInput from "./DragInput";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
-import { getStepSizedValue, isPropertyEditable } from "./utils";
+import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
 import { getElementsInAtomicUnit, resizeElement } from "./utils";
 import { getElementsInAtomicUnit, resizeElement } from "./utils";
 import type { AtomicUnit } from "./utils";
 import type { AtomicUnit } from "./utils";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
@@ -23,6 +23,7 @@ interface MultiDimensionProps {
   elementsMap: ElementsMap;
   elementsMap: ElementsMap;
   atomicUnits: AtomicUnit[];
   atomicUnits: AtomicUnit[];
   scene: Scene;
   scene: Scene;
+  appState: AppState;
 }
 }
 
 
 const STEP_SIZE = 10;
 const STEP_SIZE = 10;
@@ -131,143 +132,21 @@ const resizeGroup = (
   }
   }
 };
 };
 
 
-const MultiDimension = ({
-  property,
-  elements,
-  elementsMap,
-  atomicUnits,
+const handleDimensionChange: DragInputCallbackType<
+  MultiDimensionProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  originalElementsMap,
+  originalAppState,
+  shouldChangeByStepSize,
+  nextValue,
   scene,
   scene,
-}: MultiDimensionProps) => {
-  const sizes = useMemo(
-    () =>
-      atomicUnits.map((atomicUnit) => {
-        const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap);
-
-        if (elementsInUnit.length > 1) {
-          const [x1, y1, x2, y2] = getCommonBounds(
-            elementsInUnit.map((el) => el.latest),
-          );
-          return (
-            Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100
-          );
-        }
-        const [el] = elementsInUnit;
-
-        return (
-          Math.round(
-            (property === "width" ? el.latest.width : el.latest.height) * 100,
-          ) / 100
-        );
-      }),
-    [elementsMap, atomicUnits, property],
-  );
-
-  const value =
-    new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed";
-
-  const editable = sizes.length > 0;
-
-  const handleDimensionChange: DragInputCallbackType = ({
-    accumulatedChange,
-    originalElementsMap,
-    shouldChangeByStepSize,
-    nextValue,
-  }) => {
-    if (nextValue !== undefined) {
-      for (const atomicUnit of atomicUnits) {
-        const elementsInUnit = getElementsInAtomicUnit(
-          atomicUnit,
-          elementsMap,
-          originalElementsMap,
-        );
-
-        if (elementsInUnit.length > 1) {
-          const latestElements = elementsInUnit.map((el) => el.latest!);
-          const originalElements = elementsInUnit.map((el) => el.original!);
-          const [x1, y1, x2, y2] = getCommonBounds(originalElements);
-          const initialWidth = x2 - x1;
-          const initialHeight = y2 - y1;
-          const aspectRatio = initialWidth / initialHeight;
-          const nextWidth = Math.max(
-            MIN_WIDTH_OR_HEIGHT,
-            property === "width" ? Math.max(0, nextValue) : initialWidth,
-          );
-          const nextHeight = Math.max(
-            MIN_WIDTH_OR_HEIGHT,
-            property === "height" ? Math.max(0, nextValue) : initialHeight,
-          );
-
-          resizeGroup(
-            nextWidth,
-            nextHeight,
-            initialHeight,
-            aspectRatio,
-            [x1, y1],
-            property,
-            latestElements,
-            originalElements,
-            elementsMap,
-            originalElementsMap,
-          );
-        } else {
-          const [el] = elementsInUnit;
-          const latestElement = el?.latest;
-          const origElement = el?.original;
-
-          if (
-            latestElement &&
-            origElement &&
-            isPropertyEditable(latestElement, property)
-          ) {
-            let nextWidth =
-              property === "width"
-                ? Math.max(0, nextValue)
-                : latestElement.width;
-            if (property === "width") {
-              if (shouldChangeByStepSize) {
-                nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
-              } else {
-                nextWidth = Math.round(nextWidth);
-              }
-            }
-
-            let nextHeight =
-              property === "height"
-                ? Math.max(0, nextValue)
-                : latestElement.height;
-            if (property === "height") {
-              if (shouldChangeByStepSize) {
-                nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
-              } else {
-                nextHeight = Math.round(nextHeight);
-              }
-            }
-
-            nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
-            nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
-
-            resizeElement(
-              nextWidth,
-              nextHeight,
-              false,
-              latestElement,
-              origElement,
-              elementsMap,
-              originalElementsMap,
-              false,
-            );
-          }
-        }
-      }
-
-      scene.triggerUpdate();
-
-      return;
-    }
-
-    const changeInWidth = property === "width" ? accumulatedChange : 0;
-    const changeInHeight = property === "height" ? accumulatedChange : 0;
-
+  property,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const atomicUnits = getAtomicUnits(originalElements, originalAppState);
+  if (nextValue !== undefined) {
     for (const atomicUnit of atomicUnits) {
     for (const atomicUnit of atomicUnits) {
       const elementsInUnit = getElementsInAtomicUnit(
       const elementsInUnit = getElementsInAtomicUnit(
         atomicUnit,
         atomicUnit,
@@ -278,31 +157,18 @@ const MultiDimension = ({
       if (elementsInUnit.length > 1) {
       if (elementsInUnit.length > 1) {
         const latestElements = elementsInUnit.map((el) => el.latest!);
         const latestElements = elementsInUnit.map((el) => el.latest!);
         const originalElements = elementsInUnit.map((el) => el.original!);
         const originalElements = elementsInUnit.map((el) => el.original!);
-
         const [x1, y1, x2, y2] = getCommonBounds(originalElements);
         const [x1, y1, x2, y2] = getCommonBounds(originalElements);
         const initialWidth = x2 - x1;
         const initialWidth = x2 - x1;
         const initialHeight = y2 - y1;
         const initialHeight = y2 - y1;
         const aspectRatio = initialWidth / initialHeight;
         const aspectRatio = initialWidth / initialHeight;
-        let nextWidth = Math.max(0, initialWidth + changeInWidth);
-        if (property === "width") {
-          if (shouldChangeByStepSize) {
-            nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
-          } else {
-            nextWidth = Math.round(nextWidth);
-          }
-        }
-
-        let nextHeight = Math.max(0, initialHeight + changeInHeight);
-        if (property === "height") {
-          if (shouldChangeByStepSize) {
-            nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
-          } else {
-            nextHeight = Math.round(nextHeight);
-          }
-        }
-
-        nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
-        nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+        const nextWidth = Math.max(
+          MIN_WIDTH_OR_HEIGHT,
+          property === "width" ? Math.max(0, nextValue) : initialWidth,
+        );
+        const nextHeight = Math.max(
+          MIN_WIDTH_OR_HEIGHT,
+          property === "height" ? Math.max(0, nextValue) : initialHeight,
+        );
 
 
         resizeGroup(
         resizeGroup(
           nextWidth,
           nextWidth,
@@ -326,7 +192,8 @@ const MultiDimension = ({
           origElement &&
           origElement &&
           isPropertyEditable(latestElement, property)
           isPropertyEditable(latestElement, property)
         ) {
         ) {
-          let nextWidth = Math.max(0, origElement.width + changeInWidth);
+          let nextWidth =
+            property === "width" ? Math.max(0, nextValue) : latestElement.width;
           if (property === "width") {
           if (property === "width") {
             if (shouldChangeByStepSize) {
             if (shouldChangeByStepSize) {
               nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
               nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
@@ -335,7 +202,10 @@ const MultiDimension = ({
             }
             }
           }
           }
 
 
-          let nextHeight = Math.max(0, origElement.height + changeInHeight);
+          let nextHeight =
+            property === "height"
+              ? Math.max(0, nextValue)
+              : latestElement.height;
           if (property === "height") {
           if (property === "height") {
             if (shouldChangeByStepSize) {
             if (shouldChangeByStepSize) {
               nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
               nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
@@ -351,17 +221,145 @@ const MultiDimension = ({
             nextWidth,
             nextWidth,
             nextHeight,
             nextHeight,
             false,
             false,
-            latestElement,
             origElement,
             origElement,
             elementsMap,
             elementsMap,
-            originalElementsMap,
+            false,
           );
           );
         }
         }
       }
       }
     }
     }
 
 
     scene.triggerUpdate();
     scene.triggerUpdate();
-  };
+
+    return;
+  }
+
+  const changeInWidth = property === "width" ? accumulatedChange : 0;
+  const changeInHeight = property === "height" ? accumulatedChange : 0;
+
+  for (const atomicUnit of atomicUnits) {
+    const elementsInUnit = getElementsInAtomicUnit(
+      atomicUnit,
+      elementsMap,
+      originalElementsMap,
+    );
+
+    if (elementsInUnit.length > 1) {
+      const latestElements = elementsInUnit.map((el) => el.latest!);
+      const originalElements = elementsInUnit.map((el) => el.original!);
+
+      const [x1, y1, x2, y2] = getCommonBounds(originalElements);
+      const initialWidth = x2 - x1;
+      const initialHeight = y2 - y1;
+      const aspectRatio = initialWidth / initialHeight;
+      let nextWidth = Math.max(0, initialWidth + changeInWidth);
+      if (property === "width") {
+        if (shouldChangeByStepSize) {
+          nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
+        } else {
+          nextWidth = Math.round(nextWidth);
+        }
+      }
+
+      let nextHeight = Math.max(0, initialHeight + changeInHeight);
+      if (property === "height") {
+        if (shouldChangeByStepSize) {
+          nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
+        } else {
+          nextHeight = Math.round(nextHeight);
+        }
+      }
+
+      nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
+      nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+
+      resizeGroup(
+        nextWidth,
+        nextHeight,
+        initialHeight,
+        aspectRatio,
+        [x1, y1],
+        property,
+        latestElements,
+        originalElements,
+        elementsMap,
+        originalElementsMap,
+      );
+    } else {
+      const [el] = elementsInUnit;
+      const latestElement = el?.latest;
+      const origElement = el?.original;
+
+      if (
+        latestElement &&
+        origElement &&
+        isPropertyEditable(latestElement, property)
+      ) {
+        let nextWidth = Math.max(0, origElement.width + changeInWidth);
+        if (property === "width") {
+          if (shouldChangeByStepSize) {
+            nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
+          } else {
+            nextWidth = Math.round(nextWidth);
+          }
+        }
+
+        let nextHeight = Math.max(0, origElement.height + changeInHeight);
+        if (property === "height") {
+          if (shouldChangeByStepSize) {
+            nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
+          } else {
+            nextHeight = Math.round(nextHeight);
+          }
+        }
+
+        nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
+        nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+
+        resizeElement(nextWidth, nextHeight, false, origElement, elementsMap);
+      }
+    }
+  }
+
+  scene.triggerUpdate();
+};
+
+const MultiDimension = ({
+  property,
+  elements,
+  elementsMap,
+  atomicUnits,
+  scene,
+  appState,
+}: MultiDimensionProps) => {
+  const sizes = useMemo(
+    () =>
+      atomicUnits.map((atomicUnit) => {
+        const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap);
+
+        if (elementsInUnit.length > 1) {
+          const [x1, y1, x2, y2] = getCommonBounds(
+            elementsInUnit.map((el) => el.latest),
+          );
+          return (
+            Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100
+          );
+        }
+        const [el] = elementsInUnit;
+
+        return (
+          Math.round(
+            (property === "width" ? el.latest.width : el.latest.height) * 100,
+          ) / 100
+        );
+      }),
+    [elementsMap, atomicUnits, property],
+  );
+
+  const value =
+    new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed";
+
+  const editable = sizes.length > 0;
 
 
   return (
   return (
     <DragInput
     <DragInput
@@ -370,6 +368,9 @@ const MultiDimension = ({
       dragInputCallback={handleDimensionChange}
       dragInputCallback={handleDimensionChange}
       value={value}
       value={value}
       editable={editable}
       editable={editable}
+      appState={appState}
+      property={property}
+      scene={scene}
     />
     />
   );
   );
 };
 };

+ 84 - 60
packages/excalidraw/components/Stats/MultiFontSize.tsx

@@ -2,7 +2,6 @@ import { isTextElement, refreshTextDimensions } from "../../element";
 import { mutateElement } from "../../element/mutateElement";
 import { mutateElement } from "../../element/mutateElement";
 import { isBoundToContainer } from "../../element/typeChecks";
 import { isBoundToContainer } from "../../element/typeChecks";
 import type {
 import type {
-  ElementsMap,
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawTextElement,
   ExcalidrawTextElement,
 } from "../../element/types";
 } from "../../element/types";
@@ -12,83 +11,56 @@ import { fontSizeIcon } from "../icons";
 import StatsDragInput from "./DragInput";
 import StatsDragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue } from "./utils";
 import { getStepSizedValue } from "./utils";
+import type { AppState } from "../../types";
 
 
 interface MultiFontSizeProps {
 interface MultiFontSizeProps {
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
-  elementsMap: ElementsMap;
   scene: Scene;
   scene: Scene;
+  appState: AppState;
+  property: "fontSize";
 }
 }
 
 
 const MIN_FONT_SIZE = 4;
 const MIN_FONT_SIZE = 4;
 const STEP_SIZE = 4;
 const STEP_SIZE = 4;
 
 
-const MultiFontSize = ({
-  elements,
-  elementsMap,
-  scene,
-}: MultiFontSizeProps) => {
-  const latestTextElements = elements.filter(
-    (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
+const getApplicableTextElements = (
+  elements: readonly (ExcalidrawElement | undefined)[],
+) =>
+  elements.filter(
+    (el) =>
+      el && !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
   ) as ExcalidrawTextElement[];
   ) as ExcalidrawTextElement[];
-  const fontSizes = latestTextElements.map(
-    (textEl) => Math.round(textEl.fontSize * 10) / 10,
-  );
-  const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
-  const editable = fontSizes.length > 0;
 
 
-  const handleFontSizeChange: DragInputCallbackType = ({
-    accumulatedChange,
-    originalElements,
-    shouldChangeByStepSize,
-    nextValue,
-  }) => {
-    if (nextValue) {
-      const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
-
-      for (const textElement of latestTextElements) {
-        const newElement = {
-          ...textElement,
-          fontSize: nextFontSize,
-        };
-        const updates = refreshTextDimensions(newElement, null, elementsMap);
-        mutateElement(
-          textElement,
-          {
-            ...updates,
-            fontSize: nextFontSize,
-          },
-          false,
-        );
-      }
-
-      scene.triggerUpdate();
-      return;
-    }
-
-    const originalTextElements = originalElements.filter(
-      (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
-    ) as ExcalidrawTextElement[];
+const handleFontSizeChange: DragInputCallbackType<
+  MultiFontSizeProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  shouldChangeByStepSize,
+  nextValue,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const latestTextElements = getApplicableTextElements(
+    originalElements.map((el) => elementsMap.get(el.id)),
+  );
 
 
-    for (let i = 0; i < latestTextElements.length; i++) {
-      const latestElement = latestTextElements[i];
-      const originalElement = originalTextElements[i];
+  if (nextValue) {
+    const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
 
 
-      const originalFontSize = Math.round(originalElement.fontSize);
-      const changeInFontSize = Math.round(accumulatedChange);
-      let nextFontSize = Math.max(
-        originalFontSize + changeInFontSize,
-        MIN_FONT_SIZE,
-      );
-      if (shouldChangeByStepSize) {
-        nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
+    for (const textElement of latestTextElements.map((el) =>
+      elementsMap.get(el.id),
+    )) {
+      if (!textElement || !isTextElement(textElement)) {
+        continue;
       }
       }
       const newElement = {
       const newElement = {
-        ...latestElement,
+        ...textElement,
         fontSize: nextFontSize,
         fontSize: nextFontSize,
       };
       };
       const updates = refreshTextDimensions(newElement, null, elementsMap);
       const updates = refreshTextDimensions(newElement, null, elementsMap);
       mutateElement(
       mutateElement(
-        latestElement,
+        textElement,
         {
         {
           ...updates,
           ...updates,
           fontSize: nextFontSize,
           fontSize: nextFontSize,
@@ -98,7 +70,56 @@ const MultiFontSize = ({
     }
     }
 
 
     scene.triggerUpdate();
     scene.triggerUpdate();
-  };
+    return;
+  }
+
+  const originalTextElements = originalElements.filter(
+    (el) => !isInGroup(el) && isTextElement(el) && !isBoundToContainer(el),
+  ) as ExcalidrawTextElement[];
+
+  for (let i = 0; i < latestTextElements.length; i++) {
+    const latestElement = latestTextElements[i];
+    const originalElement = originalTextElements[i];
+
+    const originalFontSize = Math.round(originalElement.fontSize);
+    const changeInFontSize = Math.round(accumulatedChange);
+    let nextFontSize = Math.max(
+      originalFontSize + changeInFontSize,
+      MIN_FONT_SIZE,
+    );
+    if (shouldChangeByStepSize) {
+      nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
+    }
+    const newElement = {
+      ...latestElement,
+      fontSize: nextFontSize,
+    };
+    const updates = refreshTextDimensions(newElement, null, elementsMap);
+    mutateElement(
+      latestElement,
+      {
+        ...updates,
+        fontSize: nextFontSize,
+      },
+      false,
+    );
+  }
+
+  scene.triggerUpdate();
+};
+
+const MultiFontSize = ({
+  elements,
+  scene,
+  appState,
+  property,
+}: MultiFontSizeProps) => {
+  const latestTextElements = getApplicableTextElements(elements);
+  const fontSizes = latestTextElements.map(
+    (textEl) => Math.round(textEl.fontSize * 10) / 10,
+  );
+  const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
+  const editable = fontSizes.length > 0;
 
 
   return (
   return (
     <StatsDragInput
     <StatsDragInput
@@ -108,6 +129,9 @@ const MultiFontSize = ({
       dragInputCallback={handleFontSizeChange}
       dragInputCallback={handleFontSizeChange}
       value={value}
       value={value}
       editable={editable}
       editable={editable}
+      scene={scene}
+      property={property}
+      appState={appState}
     />
     />
   );
   );
 };
 };

+ 110 - 96
packages/excalidraw/components/Stats/MultiPosition.tsx

@@ -3,11 +3,12 @@ import { rotate } from "../../math";
 import type Scene from "../../scene/Scene";
 import type Scene from "../../scene/Scene";
 import StatsDragInput from "./DragInput";
 import StatsDragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
-import { getStepSizedValue, isPropertyEditable } from "./utils";
+import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
 import { getCommonBounds, isTextElement } from "../../element";
 import { getCommonBounds, isTextElement } from "../../element";
 import { useMemo } from "react";
 import { useMemo } from "react";
 import { getElementsInAtomicUnit, moveElement } from "./utils";
 import { getElementsInAtomicUnit, moveElement } from "./utils";
 import type { AtomicUnit } from "./utils";
 import type { AtomicUnit } from "./utils";
+import type { AppState } from "../../types";
 
 
 interface MultiPositionProps {
 interface MultiPositionProps {
   property: "x" | "y";
   property: "x" | "y";
@@ -15,6 +16,7 @@ interface MultiPositionProps {
   elementsMap: ElementsMap;
   elementsMap: ElementsMap;
   atomicUnits: AtomicUnit[];
   atomicUnits: AtomicUnit[];
   scene: Scene;
   scene: Scene;
+  appState: AppState;
 }
 }
 
 
 const STEP_SIZE = 10;
 const STEP_SIZE = 10;
@@ -30,7 +32,6 @@ const moveElements = (
 ) => {
 ) => {
   for (let i = 0; i < elements.length; i++) {
   for (let i = 0; i < elements.length; i++) {
     const origElement = originalElements[i];
     const origElement = originalElements[i];
-    const latestElement = elements[i];
 
 
     const [cx, cy] = [
     const [cx, cy] = [
       origElement.x + origElement.width / 2,
       origElement.x + origElement.width / 2,
@@ -53,7 +54,6 @@ const moveElements = (
     moveElement(
     moveElement(
       newTopLeftX,
       newTopLeftX,
       newTopLeftY,
       newTopLeftY,
-      latestElement,
       origElement,
       origElement,
       elementsMap,
       elementsMap,
       originalElementsMap,
       originalElementsMap,
@@ -65,7 +65,6 @@ const moveElements = (
 const moveGroupTo = (
 const moveGroupTo = (
   nextX: number,
   nextX: number,
   nextY: number,
   nextY: number,
-  latestElements: ExcalidrawElement[],
   originalElements: ExcalidrawElement[],
   originalElements: ExcalidrawElement[],
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
   originalElementsMap: ElementsMap,
   originalElementsMap: ElementsMap,
@@ -74,9 +73,13 @@ const moveGroupTo = (
   const offsetX = nextX - x1;
   const offsetX = nextX - x1;
   const offsetY = nextY - y1;
   const offsetY = nextY - y1;
 
 
-  for (let i = 0; i < latestElements.length; i++) {
+  for (let i = 0; i < originalElements.length; i++) {
     const origElement = originalElements[i];
     const origElement = originalElements[i];
-    const latestElement = latestElements[i];
+
+    const latestElement = elementsMap.get(origElement.id);
+    if (!latestElement) {
+      continue;
+    }
 
 
     // bound texts are moved with their containers
     // bound texts are moved with their containers
     if (!isTextElement(latestElement) || !latestElement.containerId) {
     if (!isTextElement(latestElement) || !latestElement.containerId) {
@@ -96,7 +99,6 @@ const moveGroupTo = (
       moveElement(
       moveElement(
         topLeftX + offsetX,
         topLeftX + offsetX,
         topLeftY + offsetY,
         topLeftY + offsetY,
-        latestElement,
         origElement,
         origElement,
         elementsMap,
         elementsMap,
         originalElementsMap,
         originalElementsMap,
@@ -106,12 +108,110 @@ const moveGroupTo = (
   }
   }
 };
 };
 
 
+const handlePositionChange: DragInputCallbackType<
+  MultiPositionProps["property"]
+> = ({
+  accumulatedChange,
+  originalElements,
+  originalElementsMap,
+  shouldChangeByStepSize,
+  nextValue,
+  property,
+  scene,
+  originalAppState,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+
+  if (nextValue !== undefined) {
+    for (const atomicUnit of getAtomicUnits(
+      originalElements,
+      originalAppState,
+    )) {
+      const elementsInUnit = getElementsInAtomicUnit(
+        atomicUnit,
+        elementsMap,
+        originalElementsMap,
+      );
+
+      if (elementsInUnit.length > 1) {
+        const [x1, y1, ,] = getCommonBounds(
+          elementsInUnit.map((el) => el.latest!),
+        );
+        const newTopLeftX = property === "x" ? nextValue : x1;
+        const newTopLeftY = property === "y" ? nextValue : y1;
+
+        moveGroupTo(
+          newTopLeftX,
+          newTopLeftY,
+          elementsInUnit.map((el) => el.original),
+          elementsMap,
+          originalElementsMap,
+        );
+      } else {
+        const origElement = elementsInUnit[0]?.original;
+        const latestElement = elementsInUnit[0]?.latest;
+        if (
+          origElement &&
+          latestElement &&
+          isPropertyEditable(latestElement, property)
+        ) {
+          const [cx, cy] = [
+            origElement.x + origElement.width / 2,
+            origElement.y + origElement.height / 2,
+          ];
+          const [topLeftX, topLeftY] = rotate(
+            origElement.x,
+            origElement.y,
+            cx,
+            cy,
+            origElement.angle,
+          );
+
+          const newTopLeftX = property === "x" ? nextValue : topLeftX;
+          const newTopLeftY = property === "y" ? nextValue : topLeftY;
+          moveElement(
+            newTopLeftX,
+            newTopLeftY,
+            origElement,
+            elementsMap,
+            originalElementsMap,
+            false,
+          );
+        }
+      }
+    }
+
+    scene.triggerUpdate();
+    return;
+  }
+
+  const change = shouldChangeByStepSize
+    ? getStepSizedValue(accumulatedChange, STEP_SIZE)
+    : accumulatedChange;
+
+  const changeInTopX = property === "x" ? change : 0;
+  const changeInTopY = property === "y" ? change : 0;
+
+  moveElements(
+    property,
+    changeInTopX,
+    changeInTopY,
+    originalElements,
+    originalElements,
+    elementsMap,
+    originalElementsMap,
+  );
+
+  scene.triggerUpdate();
+};
+
 const MultiPosition = ({
 const MultiPosition = ({
   property,
   property,
   elements,
   elements,
   elementsMap,
   elementsMap,
   atomicUnits,
   atomicUnits,
   scene,
   scene,
+  appState,
 }: MultiPositionProps) => {
 }: MultiPositionProps) => {
   const positions = useMemo(
   const positions = useMemo(
     () =>
     () =>
@@ -137,101 +237,15 @@ const MultiPosition = ({
 
 
   const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
   const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
 
 
-  const handlePositionChange: DragInputCallbackType = ({
-    accumulatedChange,
-    originalElements,
-    originalElementsMap,
-    shouldChangeByStepSize,
-    nextValue,
-  }) => {
-    if (nextValue !== undefined) {
-      for (const atomicUnit of atomicUnits) {
-        const elementsInUnit = getElementsInAtomicUnit(
-          atomicUnit,
-          elementsMap,
-          originalElementsMap,
-        );
-
-        if (elementsInUnit.length > 1) {
-          const [x1, y1, ,] = getCommonBounds(
-            elementsInUnit.map((el) => el.latest!),
-          );
-          const newTopLeftX = property === "x" ? nextValue : x1;
-          const newTopLeftY = property === "y" ? nextValue : y1;
-
-          moveGroupTo(
-            newTopLeftX,
-            newTopLeftY,
-            elementsInUnit.map((el) => el.latest),
-            elementsInUnit.map((el) => el.original),
-            elementsMap,
-            originalElementsMap,
-          );
-        } else {
-          const origElement = elementsInUnit[0]?.original;
-          const latestElement = elementsInUnit[0]?.latest;
-          if (
-            origElement &&
-            latestElement &&
-            isPropertyEditable(latestElement, property)
-          ) {
-            const [cx, cy] = [
-              origElement.x + origElement.width / 2,
-              origElement.y + origElement.height / 2,
-            ];
-            const [topLeftX, topLeftY] = rotate(
-              origElement.x,
-              origElement.y,
-              cx,
-              cy,
-              origElement.angle,
-            );
-
-            const newTopLeftX = property === "x" ? nextValue : topLeftX;
-            const newTopLeftY = property === "y" ? nextValue : topLeftY;
-            moveElement(
-              newTopLeftX,
-              newTopLeftY,
-              latestElement,
-              origElement,
-              elementsMap,
-              originalElementsMap,
-              false,
-            );
-          }
-        }
-      }
-
-      scene.triggerUpdate();
-      return;
-    }
-
-    const change = shouldChangeByStepSize
-      ? getStepSizedValue(accumulatedChange, STEP_SIZE)
-      : accumulatedChange;
-
-    const changeInTopX = property === "x" ? change : 0;
-    const changeInTopY = property === "y" ? change : 0;
-
-    moveElements(
-      property,
-      changeInTopX,
-      changeInTopY,
-      elements,
-      originalElements,
-      elementsMap,
-      originalElementsMap,
-    );
-
-    scene.triggerUpdate();
-  };
-
   return (
   return (
     <StatsDragInput
     <StatsDragInput
       label={property === "x" ? "X" : "Y"}
       label={property === "x" ? "X" : "Y"}
       elements={elements}
       elements={elements}
       dragInputCallback={handlePositionChange}
       dragInputCallback={handlePositionChange}
       value={value}
       value={value}
+      property={property}
+      scene={scene}
+      appState={appState}
     />
     />
   );
   );
 };
 };

+ 79 - 65
packages/excalidraw/components/Stats/Position.tsx

@@ -3,90 +3,101 @@ import { rotate } from "../../math";
 import StatsDragInput from "./DragInput";
 import StatsDragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, moveElement } from "./utils";
 import { getStepSizedValue, moveElement } from "./utils";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
 
 
 interface PositionProps {
 interface PositionProps {
   property: "x" | "y";
   property: "x" | "y";
   element: ExcalidrawElement;
   element: ExcalidrawElement;
   elementsMap: ElementsMap;
   elementsMap: ElementsMap;
+  scene: Scene;
+  appState: AppState;
 }
 }
 
 
 const STEP_SIZE = 10;
 const STEP_SIZE = 10;
 
 
-const Position = ({ property, element, elementsMap }: PositionProps) => {
+const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
+  accumulatedChange,
+  originalElements,
+  originalElementsMap,
+  shouldChangeByStepSize,
+  nextValue,
+  property,
+  scene,
+}) => {
+  const elementsMap = scene.getNonDeletedElementsMap();
+  const origElement = originalElements[0];
+  const [cx, cy] = [
+    origElement.x + origElement.width / 2,
+    origElement.y + origElement.height / 2,
+  ];
   const [topLeftX, topLeftY] = rotate(
   const [topLeftX, topLeftY] = rotate(
-    element.x,
-    element.y,
-    element.x + element.width / 2,
-    element.y + element.height / 2,
-    element.angle,
+    origElement.x,
+    origElement.y,
+    cx,
+    cy,
+    origElement.angle,
   );
   );
-  const value =
-    Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
-
-  const handlePositionChange: DragInputCallbackType = ({
-    accumulatedChange,
-    originalElements,
-    originalElementsMap,
-    shouldChangeByStepSize,
-    nextValue,
-  }) => {
-    const origElement = originalElements[0];
-    const [cx, cy] = [
-      origElement.x + origElement.width / 2,
-      origElement.y + origElement.height / 2,
-    ];
-    const [topLeftX, topLeftY] = rotate(
-      origElement.x,
-      origElement.y,
-      cx,
-      cy,
-      origElement.angle,
-    );
-
-    if (nextValue !== undefined) {
-      const newTopLeftX = property === "x" ? nextValue : topLeftX;
-      const newTopLeftY = property === "y" ? nextValue : topLeftY;
-      moveElement(
-        newTopLeftX,
-        newTopLeftY,
-        element,
-        origElement,
-        elementsMap,
-        originalElementsMap,
-      );
-      return;
-    }
-
-    const changeInTopX = property === "x" ? accumulatedChange : 0;
-    const changeInTopY = property === "y" ? accumulatedChange : 0;
-
-    const newTopLeftX =
-      property === "x"
-        ? Math.round(
-            shouldChangeByStepSize
-              ? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
-              : topLeftX + changeInTopX,
-          )
-        : topLeftX;
-
-    const newTopLeftY =
-      property === "y"
-        ? Math.round(
-            shouldChangeByStepSize
-              ? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
-              : topLeftY + changeInTopY,
-          )
-        : topLeftY;
 
 
+  if (nextValue !== undefined) {
+    const newTopLeftX = property === "x" ? nextValue : topLeftX;
+    const newTopLeftY = property === "y" ? nextValue : topLeftY;
     moveElement(
     moveElement(
       newTopLeftX,
       newTopLeftX,
       newTopLeftY,
       newTopLeftY,
-      element,
       origElement,
       origElement,
       elementsMap,
       elementsMap,
       originalElementsMap,
       originalElementsMap,
     );
     );
-  };
+    return;
+  }
+
+  const changeInTopX = property === "x" ? accumulatedChange : 0;
+  const changeInTopY = property === "y" ? accumulatedChange : 0;
+
+  const newTopLeftX =
+    property === "x"
+      ? Math.round(
+          shouldChangeByStepSize
+            ? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
+            : topLeftX + changeInTopX,
+        )
+      : topLeftX;
+
+  const newTopLeftY =
+    property === "y"
+      ? Math.round(
+          shouldChangeByStepSize
+            ? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
+            : topLeftY + changeInTopY,
+        )
+      : topLeftY;
+
+  moveElement(
+    newTopLeftX,
+    newTopLeftY,
+    origElement,
+    elementsMap,
+    originalElementsMap,
+  );
+};
+
+const Position = ({
+  property,
+  element,
+  elementsMap,
+  scene,
+  appState,
+}: PositionProps) => {
+  const [topLeftX, topLeftY] = rotate(
+    element.x,
+    element.y,
+    element.x + element.width / 2,
+    element.y + element.height / 2,
+    element.angle,
+  );
+  const value =
+    Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
 
 
   return (
   return (
     <StatsDragInput
     <StatsDragInput
@@ -94,6 +105,9 @@ const Position = ({ property, element, elementsMap }: PositionProps) => {
       elements={[element]}
       elements={[element]}
       dragInputCallback={handlePositionChange}
       dragInputCallback={handlePositionChange}
       value={value}
       value={value}
+      property={property}
+      scene={scene}
+      appState={appState}
     />
     />
   );
   );
 };
 };

+ 32 - 32
packages/excalidraw/components/Stats/index.tsx

@@ -11,12 +11,7 @@ import Angle from "./Angle";
 
 
 import FontSize from "./FontSize";
 import FontSize from "./FontSize";
 import MultiDimension from "./MultiDimension";
 import MultiDimension from "./MultiDimension";
-import {
-  elementsAreInSameGroup,
-  getElementsInGroup,
-  getSelectedGroupIds,
-  isInGroup,
-} from "../../groups";
+import { elementsAreInSameGroup } from "../../groups";
 import MultiAngle from "./MultiAngle";
 import MultiAngle from "./MultiAngle";
 import MultiFontSize from "./MultiFontSize";
 import MultiFontSize from "./MultiFontSize";
 import Position from "./Position";
 import Position from "./Position";
@@ -24,8 +19,9 @@ import MultiPosition from "./MultiPosition";
 import Collapsible from "./Collapsible";
 import Collapsible from "./Collapsible";
 import type Scene from "../../scene/Scene";
 import type Scene from "../../scene/Scene";
 import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
 import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
-import type { AtomicUnit } from "./utils";
+import { getAtomicUnits } from "./utils";
 import { STATS_PANELS } from "../../constants";
 import { STATS_PANELS } from "../../constants";
+import { isTextElement } from "../../element";
 
 
 interface StatsProps {
 interface StatsProps {
   scene: Scene;
   scene: Scene;
@@ -106,21 +102,7 @@ export const StatsInner = memo(
     );
     );
 
 
     const atomicUnits = useMemo(() => {
     const atomicUnits = useMemo(() => {
-      const selectedGroupIds = getSelectedGroupIds(appState);
-      const _atomicUnits = selectedGroupIds.map((gid) => {
-        return getElementsInGroup(selectedElements, gid).reduce((acc, el) => {
-          acc[el.id] = true;
-          return acc;
-        }, {} as AtomicUnit);
-      });
-      selectedElements
-        .filter((el) => !isInGroup(el))
-        .forEach((el) => {
-          _atomicUnits.push({
-            [el.id]: true,
-          });
-        });
-      return _atomicUnits;
+      return getAtomicUnits(selectedElements, appState);
     }, [selectedElements, appState]);
     }, [selectedElements, appState]);
 
 
     return (
     return (
@@ -206,30 +188,40 @@ export const StatsInner = memo(
                         element={singleElement}
                         element={singleElement}
                         property="x"
                         property="x"
                         elementsMap={elementsMap}
                         elementsMap={elementsMap}
+                        scene={scene}
+                        appState={appState}
                       />
                       />
                       <Position
                       <Position
                         element={singleElement}
                         element={singleElement}
                         property="y"
                         property="y"
                         elementsMap={elementsMap}
                         elementsMap={elementsMap}
+                        scene={scene}
+                        appState={appState}
                       />
                       />
                       <Dimension
                       <Dimension
                         property="width"
                         property="width"
                         element={singleElement}
                         element={singleElement}
-                        elementsMap={elementsMap}
+                        scene={scene}
+                        appState={appState}
                       />
                       />
                       <Dimension
                       <Dimension
                         property="height"
                         property="height"
                         element={singleElement}
                         element={singleElement}
-                        elementsMap={elementsMap}
+                        scene={scene}
+                        appState={appState}
                       />
                       />
                       <Angle
                       <Angle
+                        property="angle"
                         element={singleElement}
                         element={singleElement}
-                        elementsMap={elementsMap}
+                        scene={scene}
+                        appState={appState}
                       />
                       />
                       {singleElement.type === "text" && (
                       {singleElement.type === "text" && (
                         <FontSize
                         <FontSize
+                          property="fontSize"
                           element={singleElement}
                           element={singleElement}
-                          elementsMap={elementsMap}
+                          scene={scene}
+                          appState={appState}
                         />
                         />
                       )}
                       )}
                     </div>
                     </div>
@@ -254,6 +246,7 @@ export const StatsInner = memo(
                         elementsMap={elementsMap}
                         elementsMap={elementsMap}
                         atomicUnits={atomicUnits}
                         atomicUnits={atomicUnits}
                         scene={scene}
                         scene={scene}
+                        appState={appState}
                       />
                       />
                       <MultiPosition
                       <MultiPosition
                         property="y"
                         property="y"
@@ -261,6 +254,7 @@ export const StatsInner = memo(
                         elementsMap={elementsMap}
                         elementsMap={elementsMap}
                         atomicUnits={atomicUnits}
                         atomicUnits={atomicUnits}
                         scene={scene}
                         scene={scene}
+                        appState={appState}
                       />
                       />
                       <MultiDimension
                       <MultiDimension
                         property="width"
                         property="width"
@@ -268,6 +262,7 @@ export const StatsInner = memo(
                         elementsMap={elementsMap}
                         elementsMap={elementsMap}
                         atomicUnits={atomicUnits}
                         atomicUnits={atomicUnits}
                         scene={scene}
                         scene={scene}
+                        appState={appState}
                       />
                       />
                       <MultiDimension
                       <MultiDimension
                         property="height"
                         property="height"
@@ -275,17 +270,22 @@ export const StatsInner = memo(
                         elementsMap={elementsMap}
                         elementsMap={elementsMap}
                         atomicUnits={atomicUnits}
                         atomicUnits={atomicUnits}
                         scene={scene}
                         scene={scene}
+                        appState={appState}
                       />
                       />
                       <MultiAngle
                       <MultiAngle
+                        property="angle"
                         elements={multipleElements}
                         elements={multipleElements}
-                        elementsMap={elementsMap}
-                        scene={scene}
-                      />
-                      <MultiFontSize
-                        elements={multipleElements}
-                        elementsMap={elementsMap}
                         scene={scene}
                         scene={scene}
+                        appState={appState}
                       />
                       />
+                      {multipleElements.some((el) => isTextElement(el)) && (
+                        <MultiFontSize
+                          property="fontSize"
+                          elements={multipleElements}
+                          scene={scene}
+                          appState={appState}
+                        />
+                      )}
                     </div>
                     </div>
                   </div>
                   </div>
                 )}
                 )}

+ 20 - 43
packages/excalidraw/components/Stats/stats.test.tsx

@@ -30,6 +30,12 @@ const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
 let stats: HTMLElement | null = null;
 let stats: HTMLElement | null = null;
 let elementStats: HTMLElement | null | undefined = null;
 let elementStats: HTMLElement | null | undefined = null;
 
 
+const editInput = (input: HTMLInputElement, value: string) => {
+  input.focus();
+  fireEvent.change(input, { target: { value } });
+  input.blur();
+};
+
 const getStatsProperty = (label: string) => {
 const getStatsProperty = (label: string) => {
   if (elementStats) {
   if (elementStats) {
     const properties = elementStats?.querySelector(".statsItem");
     const properties = elementStats?.querySelector(".statsItem");
@@ -53,9 +59,7 @@ const testInputProperty = (
   ) as HTMLInputElement;
   ) as HTMLInputElement;
   expect(input).not.toBeNull();
   expect(input).not.toBeNull();
   expect(input.value).toBe(initialValue.toString());
   expect(input.value).toBe(initialValue.toString());
-  input?.focus();
-  input.value = nextValue.toString();
-  input?.blur();
+  editInput(input, String(nextValue));
   if (property === "angle") {
   if (property === "angle") {
     expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
     expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
   } else if (property === "fontSize" && isTextElement(element)) {
   } else if (property === "fontSize" && isTextElement(element)) {
@@ -172,17 +176,13 @@ describe("stats for a generic element", () => {
     ) as HTMLInputElement;
     ) as HTMLInputElement;
     expect(input).not.toBeNull();
     expect(input).not.toBeNull();
     expect(input.value).toBe(rectangle.width.toString());
     expect(input.value).toBe(rectangle.width.toString());
-    input?.focus();
-    input.value = "123.123";
-    input?.blur();
+    editInput(input, "123.123");
     expect(h.elements.length).toBe(1);
     expect(h.elements.length).toBe(1);
     expect(rectangle.id).toBe(rectangleId);
     expect(rectangle.id).toBe(rectangleId);
     expect(input.value).toBe("123.12");
     expect(input.value).toBe("123.12");
     expect(rectangle.width).toBe(123.12);
     expect(rectangle.width).toBe(123.12);
 
 
-    input?.focus();
-    input.value = "88.98766";
-    input?.blur();
+    editInput(input, "88.98766");
     expect(input.value).toBe("88.99");
     expect(input.value).toBe("88.99");
     expect(rectangle.width).toBe(88.99);
     expect(rectangle.width).toBe(88.99);
   });
   });
@@ -335,9 +335,7 @@ describe("stats for a non-generic element", () => {
     ) as HTMLInputElement;
     ) as HTMLInputElement;
     expect(input).not.toBeNull();
     expect(input).not.toBeNull();
     expect(input.value).toBe(text.fontSize.toString());
     expect(input.value).toBe(text.fontSize.toString());
-    input?.focus();
-    input.value = "36";
-    input?.blur();
+    editInput(input, "36");
     expect(text.fontSize).toBe(36);
     expect(text.fontSize).toBe(36);
 
 
     // cannot change width or height
     // cannot change width or height
@@ -347,9 +345,7 @@ describe("stats for a non-generic element", () => {
     expect(height).toBeUndefined();
     expect(height).toBeUndefined();
 
 
     // min font size is 4
     // min font size is 4
-    input.focus();
-    input.value = "0";
-    input.blur();
+    editInput(input, "0");
     expect(text.fontSize).not.toBe(0);
     expect(text.fontSize).not.toBe(0);
     expect(text.fontSize).toBe(4);
     expect(text.fontSize).toBe(4);
   });
   });
@@ -471,16 +467,12 @@ describe("stats for multiple elements", () => {
     ) as HTMLInputElement;
     ) as HTMLInputElement;
     expect(angle.value).toBe("0");
     expect(angle.value).toBe("0");
 
 
-    width.focus();
-    width.value = "250";
-    width.blur();
+    editInput(width, "250");
     h.elements.forEach((el) => {
     h.elements.forEach((el) => {
       expect(el.width).toBe(250);
       expect(el.width).toBe(250);
     });
     });
 
 
-    height.focus();
-    height.value = "450";
-    height.blur();
+    editInput(height, "450");
     h.elements.forEach((el) => {
     h.elements.forEach((el) => {
       expect(el.height).toBe(450);
       expect(el.height).toBe(450);
     });
     });
@@ -501,7 +493,6 @@ describe("stats for multiple elements", () => {
     mouse.up(200, 100);
     mouse.up(200, 100);
 
 
     const frame = API.createElement({
     const frame = API.createElement({
-      id: "id0",
       type: "frame",
       type: "frame",
       x: 150,
       x: 150,
       width: 150,
       width: 150,
@@ -545,17 +536,13 @@ describe("stats for multiple elements", () => {
     expect(fontSize).not.toBeNull();
     expect(fontSize).not.toBeNull();
 
 
     // changing width does not affect text
     // changing width does not affect text
-    width.focus();
-    width.value = "200";
-    width.blur();
+    editInput(width, "200");
 
 
     expect(rectangle?.width).toBe(200);
     expect(rectangle?.width).toBe(200);
     expect(frame.width).toBe(200);
     expect(frame.width).toBe(200);
     expect(text?.width).not.toBe(200);
     expect(text?.width).not.toBe(200);
 
 
-    angle.focus();
-    angle.value = "40";
-    angle.blur();
+    editInput(angle, "40");
 
 
     const angleInRadian = degreeToRadian(40);
     const angleInRadian = degreeToRadian(40);
     expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
     expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
@@ -595,9 +582,7 @@ describe("stats for multiple elements", () => {
     expect(x).not.toBeNull();
     expect(x).not.toBeNull();
     expect(Number(x.value)).toBe(x1);
     expect(Number(x.value)).toBe(x1);
 
 
-    x.focus();
-    x.value = "300";
-    x.blur();
+    editInput(x, "300");
 
 
     expect(h.elements[0].x).toBe(300);
     expect(h.elements[0].x).toBe(300);
     expect(h.elements[1].x).toBe(400);
     expect(h.elements[1].x).toBe(400);
@@ -610,9 +595,7 @@ describe("stats for multiple elements", () => {
     expect(y).not.toBeNull();
     expect(y).not.toBeNull();
     expect(Number(y.value)).toBe(y1);
     expect(Number(y.value)).toBe(y1);
 
 
-    y.focus();
-    y.value = "200";
-    y.blur();
+    editInput(y, "200");
 
 
     expect(h.elements[0].y).toBe(200);
     expect(h.elements[0].y).toBe(200);
     expect(h.elements[1].y).toBe(300);
     expect(h.elements[1].y).toBe(300);
@@ -630,26 +613,20 @@ describe("stats for multiple elements", () => {
     expect(height).not.toBeNull();
     expect(height).not.toBeNull();
     expect(Number(height.value)).toBe(200);
     expect(Number(height.value)).toBe(200);
 
 
-    width.focus();
-    width.value = "400";
-    width.blur();
+    editInput(width, "400");
 
 
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     let newGroupWidth = x2 - x1;
     let newGroupWidth = x2 - x1;
 
 
     expect(newGroupWidth).toBeCloseTo(400, 4);
     expect(newGroupWidth).toBeCloseTo(400, 4);
 
 
-    width.focus();
-    width.value = "300";
-    width.blur();
+    editInput(width, "300");
 
 
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     newGroupWidth = x2 - x1;
     newGroupWidth = x2 - x1;
     expect(newGroupWidth).toBeCloseTo(300, 4);
     expect(newGroupWidth).toBeCloseTo(300, 4);
 
 
-    height.focus();
-    height.value = "500";
-    height.blur();
+    editInput(height, "500");
 
 
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
     const newGroupHeight = y2 - y1;
     const newGroupHeight = y2 - y1;

+ 43 - 3
packages/excalidraw/components/Stats/utils.ts

@@ -17,9 +17,23 @@ import type {
   ExcalidrawElement,
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
 } from "../../element/types";
 } from "../../element/types";
+import {
+  getSelectedGroupIds,
+  getElementsInGroup,
+  isInGroup,
+} from "../../groups";
 import { rotate } from "../../math";
 import { rotate } from "../../math";
+import type { AppState } from "../../types";
 import { getFontString } from "../../utils";
 import { getFontString } from "../../utils";
 
 
+export type StatsInputProperty =
+  | "x"
+  | "y"
+  | "width"
+  | "height"
+  | "angle"
+  | "fontSize";
+
 export const SMALLEST_DELTA = 0.01;
 export const SMALLEST_DELTA = 0.01;
 
 
 export const isPropertyEditable = (
 export const isPropertyEditable = (
@@ -100,12 +114,14 @@ export const resizeElement = (
   nextWidth: number,
   nextWidth: number,
   nextHeight: number,
   nextHeight: number,
   keepAspectRatio: boolean,
   keepAspectRatio: boolean,
-  latestElement: ExcalidrawElement,
   origElement: ExcalidrawElement,
   origElement: ExcalidrawElement,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
-  originalElementsMap: Map<string, ExcalidrawElement>,
   shouldInformMutation = true,
   shouldInformMutation = true,
 ) => {
 ) => {
+  const latestElement = elementsMap.get(origElement.id);
+  if (!latestElement) {
+    return;
+  }
   let boundTextFont: { fontSize?: number } = {};
   let boundTextFont: { fontSize?: number } = {};
   const boundTextElement = getBoundTextElement(latestElement, elementsMap);
   const boundTextElement = getBoundTextElement(latestElement, elementsMap);
 
 
@@ -181,12 +197,15 @@ export const resizeElement = (
 export const moveElement = (
 export const moveElement = (
   newTopLeftX: number,
   newTopLeftX: number,
   newTopLeftY: number,
   newTopLeftY: number,
-  latestElement: ExcalidrawElement,
   originalElement: ExcalidrawElement,
   originalElement: ExcalidrawElement,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
   originalElementsMap: ElementsMap,
   originalElementsMap: ElementsMap,
   shouldInformMutation = true,
   shouldInformMutation = true,
 ) => {
 ) => {
+  const latestElement = elementsMap.get(originalElement.id);
+  if (!latestElement) {
+    return;
+  }
   const [cx, cy] = [
   const [cx, cy] = [
     originalElement.x + originalElement.width / 2,
     originalElement.x + originalElement.width / 2,
     originalElement.y + originalElement.height / 2,
     originalElement.y + originalElement.height / 2,
@@ -236,3 +255,24 @@ export const moveElement = (
       );
       );
   }
   }
 };
 };
+
+export const getAtomicUnits = (
+  targetElements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => {
+  const selectedGroupIds = getSelectedGroupIds(appState);
+  const _atomicUnits = selectedGroupIds.map((gid) => {
+    return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
+      acc[el.id] = true;
+      return acc;
+    }, {} as AtomicUnit);
+  });
+  targetElements
+    .filter((el) => !isInGroup(el))
+    .forEach((el) => {
+      _atomicUnits.push({
+        [el.id]: true,
+      });
+    });
+  return _atomicUnits;
+};

+ 1 - 1
packages/excalidraw/renderer/renderElement.ts

@@ -90,7 +90,7 @@ const shouldResetImageFilter = (
 };
 };
 
 
 const getCanvasPadding = (element: ExcalidrawElement) =>
 const getCanvasPadding = (element: ExcalidrawElement) =>
-  element.type === "freedraw" ? element.strokeWidth * 12 : 20;
+  element.type === "freedraw" ? element.strokeWidth * 12 : 200;
 
 
 export const getRenderOpacity = (
 export const getRenderOpacity = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,

+ 0 - 5
packages/excalidraw/scene/types.ts

@@ -2,7 +2,6 @@ import type { RoughCanvas } from "roughjs/bin/canvas";
 import type { Drawable } from "roughjs/bin/core";
 import type { Drawable } from "roughjs/bin/core";
 import type {
 import type {
   ExcalidrawElement,
   ExcalidrawElement,
-  ExcalidrawTextElement,
   NonDeletedElementsMap,
   NonDeletedElementsMap,
   NonDeletedExcalidrawElement,
   NonDeletedExcalidrawElement,
   NonDeletedSceneElementsMap,
   NonDeletedSceneElementsMap,
@@ -96,10 +95,6 @@ export type SceneScroll = {
   scrollY: number;
   scrollY: number;
 };
 };
 
 
-export interface Scene {
-  elements: ExcalidrawTextElement[];
-}
-
 export type ExportType =
 export type ExportType =
   | "png"
   | "png"
   | "clipboard"
   | "clipboard"