aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/Stats
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/components/Stats')
-rw-r--r--packages/excalidraw/components/Stats/Angle.tsx95
-rw-r--r--packages/excalidraw/components/Stats/CanvasGrid.tsx67
-rw-r--r--packages/excalidraw/components/Stats/Collapsible.tsx46
-rw-r--r--packages/excalidraw/components/Stats/Dimension.tsx272
-rw-r--r--packages/excalidraw/components/Stats/DragInput.scss76
-rw-r--r--packages/excalidraw/components/Stats/DragInput.tsx355
-rw-r--r--packages/excalidraw/components/Stats/FontSize.tsx99
-rw-r--r--packages/excalidraw/components/Stats/MultiAngle.tsx136
-rw-r--r--packages/excalidraw/components/Stats/MultiDimension.tsx401
-rw-r--r--packages/excalidraw/components/Stats/MultiFontSize.tsx164
-rw-r--r--packages/excalidraw/components/Stats/MultiPosition.tsx270
-rw-r--r--packages/excalidraw/components/Stats/Position.tsx214
-rw-r--r--packages/excalidraw/components/Stats/Stats.scss72
-rw-r--r--packages/excalidraw/components/Stats/index.tsx434
-rw-r--r--packages/excalidraw/components/Stats/stats.test.tsx724
-rw-r--r--packages/excalidraw/components/Stats/utils.ts219
16 files changed, 3644 insertions, 0 deletions
diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx
new file mode 100644
index 0000000..409476a
--- /dev/null
+++ b/packages/excalidraw/components/Stats/Angle.tsx
@@ -0,0 +1,95 @@
+import { mutateElement } from "../../element/mutateElement";
+import { getBoundTextElement } from "../../element/textElement";
+import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
+import type { ExcalidrawElement } from "../../element/types";
+import { angleIcon } from "../icons";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import type { Degrees } from "@excalidraw/math";
+import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
+
+interface AngleProps {
+ element: ExcalidrawElement;
+ scene: Scene;
+ appState: AppState;
+ property: "angle";
+}
+
+const STEP_SIZE = 15;
+
+const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
+ accumulatedChange,
+ originalElements,
+ shouldChangeByStepSize,
+ nextValue,
+ scene,
+}) => {
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const elements = scene.getNonDeletedElements();
+ const origElement = originalElements[0];
+ if (origElement && !isElbowArrow(origElement)) {
+ const latestElement = elementsMap.get(origElement.id);
+ if (!latestElement) {
+ return;
+ }
+
+ if (nextValue !== undefined) {
+ const nextAngle = degreesToRadians(nextValue as Degrees);
+ mutateElement(latestElement, {
+ angle: nextAngle,
+ });
+ updateBindings(latestElement, elementsMap, elements, scene);
+
+ const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+ if (boundTextElement && !isArrowElement(latestElement)) {
+ mutateElement(boundTextElement, { angle: nextAngle });
+ }
+
+ return;
+ }
+
+ const originalAngleInDegrees =
+ Math.round(radiansToDegrees(origElement.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 = degreesToRadians(nextAngleInDegrees as Degrees);
+
+ mutateElement(latestElement, {
+ angle: nextAngle,
+ });
+ updateBindings(latestElement, elementsMap, elements, scene);
+
+ const boundTextElement = getBoundTextElement(latestElement, elementsMap);
+ if (boundTextElement && !isArrowElement(latestElement)) {
+ mutateElement(boundTextElement, { angle: nextAngle });
+ }
+ }
+};
+
+const Angle = ({ element, scene, appState, property }: AngleProps) => {
+ return (
+ <DragInput
+ label="A"
+ icon={angleIcon}
+ value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
+ elements={[element]}
+ dragInputCallback={handleDegreeChange}
+ editable={isPropertyEditable(element, "angle")}
+ scene={scene}
+ appState={appState}
+ property={property}
+ />
+ );
+};
+
+export default Angle;
diff --git a/packages/excalidraw/components/Stats/CanvasGrid.tsx b/packages/excalidraw/components/Stats/CanvasGrid.tsx
new file mode 100644
index 0000000..a08f709
--- /dev/null
+++ b/packages/excalidraw/components/Stats/CanvasGrid.tsx
@@ -0,0 +1,67 @@
+import StatsDragInput from "./DragInput";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import { getStepSizedValue } from "./utils";
+import { getNormalizedGridStep } from "../../scene";
+
+interface PositionProps {
+ property: "gridStep";
+ scene: Scene;
+ appState: AppState;
+ setAppState: React.Component<any, AppState>["setState"];
+}
+
+const STEP_SIZE = 5;
+
+const CanvasGrid = ({
+ property,
+ scene,
+ appState,
+ setAppState,
+}: PositionProps) => {
+ return (
+ <StatsDragInput
+ label="Grid step"
+ sensitivity={8}
+ elements={[]}
+ dragInputCallback={({
+ nextValue,
+ instantChange,
+ shouldChangeByStepSize,
+ setInputValue,
+ }) => {
+ setAppState((state) => {
+ let nextGridStep;
+
+ if (nextValue) {
+ nextGridStep = nextValue;
+ } else if (instantChange) {
+ nextGridStep = shouldChangeByStepSize
+ ? getStepSizedValue(
+ state.gridStep + STEP_SIZE * Math.sign(instantChange),
+ STEP_SIZE,
+ )
+ : state.gridStep + instantChange;
+ }
+
+ if (!nextGridStep) {
+ setInputValue(state.gridStep);
+ return null;
+ }
+
+ nextGridStep = getNormalizedGridStep(nextGridStep);
+ setInputValue(nextGridStep);
+ return {
+ gridStep: nextGridStep,
+ };
+ });
+ }}
+ scene={scene}
+ value={appState.gridStep}
+ property={property}
+ appState={appState}
+ />
+ );
+};
+
+export default CanvasGrid;
diff --git a/packages/excalidraw/components/Stats/Collapsible.tsx b/packages/excalidraw/components/Stats/Collapsible.tsx
new file mode 100644
index 0000000..13d476d
--- /dev/null
+++ b/packages/excalidraw/components/Stats/Collapsible.tsx
@@ -0,0 +1,46 @@
+import { InlineIcon } from "../InlineIcon";
+import { collapseDownIcon, collapseUpIcon } from "../icons";
+
+interface CollapsibleProps {
+ label: React.ReactNode;
+ // having it controlled so that the state is managed outside
+ // this is to keep the user's previous choice even when the
+ // Collapsible is unmounted
+ open: boolean;
+ openTrigger: () => void;
+ children: React.ReactNode;
+ className?: string;
+}
+
+const Collapsible = ({
+ label,
+ open,
+ openTrigger,
+ children,
+ className,
+}: CollapsibleProps) => {
+ return (
+ <>
+ <div
+ style={{
+ cursor: "pointer",
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ }}
+ className={className}
+ onClick={openTrigger}
+ >
+ {label}
+ <InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
+ </div>
+ {open && (
+ <div style={{ display: "flex", flexDirection: "column" }}>
+ {children}
+ </div>
+ )}
+ </>
+ );
+};
+
+export default Collapsible;
diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx
new file mode 100644
index 0000000..ce096d2
--- /dev/null
+++ b/packages/excalidraw/components/Stats/Dimension.tsx
@@ -0,0 +1,272 @@
+import type { ExcalidrawElement } from "../../element/types";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, isPropertyEditable } from "./utils";
+import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
+import { resizeSingleElement } from "../../element/resizeElements";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import { isImageElement } from "../../element/typeChecks";
+import {
+ MINIMAL_CROP_SIZE,
+ getUncroppedWidthAndHeight,
+} from "../../element/cropElement";
+import { mutateElement } from "../../element/mutateElement";
+import { clamp, round } from "@excalidraw/math";
+
+interface DimensionDragInputProps {
+ property: "width" | "height";
+ element: ExcalidrawElement;
+ scene: Scene;
+ appState: AppState;
+}
+
+const STEP_SIZE = 10;
+const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
+ return element.type === "image";
+};
+
+const handleDimensionChange: DragInputCallbackType<
+ DimensionDragInputProps["property"]
+> = ({
+ accumulatedChange,
+ originalElements,
+ originalElementsMap,
+ shouldKeepAspectRatio,
+ shouldChangeByStepSize,
+ nextValue,
+ property,
+ originalAppState,
+ instantChange,
+ scene,
+}) => {
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const origElement = originalElements[0];
+ const latestElement = elementsMap.get(origElement.id);
+ if (origElement && latestElement) {
+ const keepAspectRatio =
+ shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
+ const aspectRatio = origElement.width / origElement.height;
+
+ if (originalAppState.croppingElementId === origElement.id) {
+ const element = elementsMap.get(origElement.id);
+
+ if (!element || !isImageElement(element) || !element.crop) {
+ return;
+ }
+
+ const crop = element.crop;
+ let nextCrop = { ...crop };
+
+ const isFlippedByX = element.scale[0] === -1;
+ const isFlippedByY = element.scale[1] === -1;
+
+ const { width: uncroppedWidth, height: uncroppedHeight } =
+ getUncroppedWidthAndHeight(element);
+
+ const naturalToUncroppedWidthRatio = crop.naturalWidth / uncroppedWidth;
+ const naturalToUncroppedHeightRatio =
+ crop.naturalHeight / uncroppedHeight;
+
+ const MAX_POSSIBLE_WIDTH = isFlippedByX
+ ? crop.width + crop.x
+ : crop.naturalWidth - crop.x;
+
+ const MAX_POSSIBLE_HEIGHT = isFlippedByY
+ ? crop.height + crop.y
+ : crop.naturalHeight - crop.y;
+
+ const MIN_WIDTH = MINIMAL_CROP_SIZE * naturalToUncroppedWidthRatio;
+ const MIN_HEIGHT = MINIMAL_CROP_SIZE * naturalToUncroppedHeightRatio;
+
+ if (nextValue !== undefined) {
+ if (property === "width") {
+ const nextValueInNatural = nextValue * naturalToUncroppedWidthRatio;
+
+ const nextCropWidth = clamp(
+ nextValueInNatural,
+ MIN_WIDTH,
+ MAX_POSSIBLE_WIDTH,
+ );
+
+ nextCrop = {
+ ...nextCrop,
+ width: nextCropWidth,
+ x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
+ };
+ } else if (property === "height") {
+ const nextValueInNatural = nextValue * naturalToUncroppedHeightRatio;
+ const nextCropHeight = clamp(
+ nextValueInNatural,
+ MIN_HEIGHT,
+ MAX_POSSIBLE_HEIGHT,
+ );
+
+ nextCrop = {
+ ...nextCrop,
+ height: nextCropHeight,
+ y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
+ };
+ }
+
+ mutateElement(element, {
+ crop: nextCrop,
+ width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
+ height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
+ });
+ return;
+ }
+
+ const changeInWidth = property === "width" ? instantChange : 0;
+ const changeInHeight = property === "height" ? instantChange : 0;
+
+ const nextCropWidth = clamp(
+ crop.width + changeInWidth,
+ MIN_WIDTH,
+ MAX_POSSIBLE_WIDTH,
+ );
+
+ const nextCropHeight = clamp(
+ crop.height + changeInHeight,
+ MIN_WIDTH,
+ MAX_POSSIBLE_HEIGHT,
+ );
+
+ nextCrop = {
+ ...crop,
+ x: isFlippedByX ? crop.x + crop.width - nextCropWidth : crop.x,
+ y: isFlippedByY ? crop.y + crop.height - nextCropHeight : crop.y,
+ width: nextCropWidth,
+ height: nextCropHeight,
+ };
+
+ mutateElement(element, {
+ crop: nextCrop,
+ width: nextCrop.width / (crop.naturalWidth / uncroppedWidth),
+ height: nextCrop.height / (crop.naturalHeight / uncroppedHeight),
+ });
+
+ return;
+ }
+
+ 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,
+ );
+
+ resizeSingleElement(
+ nextWidth,
+ nextHeight,
+ latestElement,
+ origElement,
+ elementsMap,
+ originalElementsMap,
+ property === "width" ? "e" : "s",
+ {
+ shouldMaintainAspectRatio: keepAspectRatio,
+ },
+ );
+
+ 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 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;
+ }
+ }
+
+ nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
+ nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
+
+ resizeSingleElement(
+ nextWidth,
+ nextHeight,
+ latestElement,
+ origElement,
+ elementsMap,
+ originalElementsMap,
+ property === "width" ? "e" : "s",
+ {
+ shouldMaintainAspectRatio: keepAspectRatio,
+ },
+ );
+ }
+};
+
+const DimensionDragInput = ({
+ property,
+ element,
+ scene,
+ appState,
+}: DimensionDragInputProps) => {
+ let value = round(property === "width" ? element.width : element.height, 2);
+
+ if (
+ appState.croppingElementId &&
+ appState.croppingElementId === element.id &&
+ isImageElement(element) &&
+ element.crop
+ ) {
+ const { width: uncroppedWidth, height: uncroppedHeight } =
+ getUncroppedWidthAndHeight(element);
+ if (property === "width") {
+ const ratio = uncroppedWidth / element.crop.naturalWidth;
+ value = round(element.crop.width * ratio, 2);
+ }
+ if (property === "height") {
+ const ratio = uncroppedHeight / element.crop.naturalHeight;
+ value = round(element.crop.height * ratio, 2);
+ }
+ }
+
+ return (
+ <DragInput
+ label={property === "width" ? "W" : "H"}
+ elements={[element]}
+ dragInputCallback={handleDimensionChange}
+ value={value}
+ editable={isPropertyEditable(element, property)}
+ scene={scene}
+ appState={appState}
+ property={property}
+ />
+ );
+};
+
+export default DimensionDragInput;
diff --git a/packages/excalidraw/components/Stats/DragInput.scss b/packages/excalidraw/components/Stats/DragInput.scss
new file mode 100644
index 0000000..76b9d14
--- /dev/null
+++ b/packages/excalidraw/components/Stats/DragInput.scss
@@ -0,0 +1,76 @@
+.excalidraw {
+ .drag-input-container {
+ display: flex;
+ width: 100%;
+
+ &:focus-within {
+ box-shadow: 0 0 0 1px var(--color-primary-darkest);
+ border-radius: var(--border-radius-md);
+ }
+ }
+
+ .disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ }
+
+ .drag-input-label {
+ flex-shrink: 0;
+ border: 1px solid var(--default-border-color);
+ border-right: 0;
+ padding: 0 0.5rem 0 0.75rem;
+ min-width: 1rem;
+ height: 2rem;
+ box-sizing: border-box;
+ color: var(--popup-text-color);
+
+ :root[dir="ltr"] & {
+ border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
+ }
+
+ :root[dir="rtl"] & {
+ border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
+ border-right: 1px solid var(--default-border-color);
+ border-left: 0;
+ }
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ }
+
+ .drag-input {
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ font-size: 0.875rem;
+ font-family: inherit;
+ background-color: transparent;
+ color: var(--text-primary-color);
+ border: 0;
+ outline: none;
+ height: 2rem;
+ border: 1px solid var(--default-border-color);
+ border-left: 0;
+ letter-spacing: 0.4px;
+
+ :root[dir="ltr"] & {
+ border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0;
+ }
+
+ :root[dir="rtl"] & {
+ border-radius: var(--border-radius-md) 0 0 var(--border-radius-md);
+ border-left: 1px solid var(--default-border-color);
+ border-right: 0;
+ }
+
+ padding: 0.5rem;
+ padding-left: 0.25rem;
+ appearance: none;
+
+ &:focus-visible {
+ box-shadow: none;
+ }
+ }
+}
diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx
new file mode 100644
index 0000000..82d6419
--- /dev/null
+++ b/packages/excalidraw/components/Stats/DragInput.tsx
@@ -0,0 +1,355 @@
+import { useEffect, useRef, useState } from "react";
+import { EVENT } from "../../constants";
+import { KEYS } from "../../keys";
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import { deepCopyElement } from "../../element/newElement";
+import clsx from "clsx";
+import { useApp } from "../App";
+import { InlineIcon } from "../InlineIcon";
+import type { StatsInputProperty } from "./utils";
+import { SMALLEST_DELTA } from "./utils";
+import { CaptureUpdateAction } from "../../store";
+import type Scene from "../../scene/Scene";
+
+import "./DragInput.scss";
+import type { AppState } from "../../types";
+import { cloneJSON } from "../../utils";
+
+export type DragInputCallbackType<
+ P extends StatsInputProperty,
+ E = ExcalidrawElement,
+> = (props: {
+ accumulatedChange: number;
+ instantChange: number;
+ originalElements: readonly E[];
+ originalElementsMap: ElementsMap;
+ shouldKeepAspectRatio: boolean;
+ shouldChangeByStepSize: boolean;
+ scene: Scene;
+ nextValue?: number;
+ property: P;
+ originalAppState: AppState;
+ setInputValue: (value: number) => void;
+}) => void;
+
+interface StatsDragInputProps<
+ T extends StatsInputProperty,
+ E = ExcalidrawElement,
+> {
+ label: string | React.ReactNode;
+ icon?: React.ReactNode;
+ value: number | "Mixed";
+ elements: readonly E[];
+ editable?: boolean;
+ shouldKeepAspectRatio?: boolean;
+ dragInputCallback: DragInputCallbackType<T, E>;
+ property: T;
+ scene: Scene;
+ appState: AppState;
+ /** how many px you need to drag to get 1 unit change */
+ sensitivity?: number;
+}
+
+const StatsDragInput = <
+ T extends StatsInputProperty,
+ E extends ExcalidrawElement = ExcalidrawElement,
+>({
+ label,
+ icon,
+ dragInputCallback,
+ value,
+ elements,
+ editable = true,
+ shouldKeepAspectRatio,
+ property,
+ scene,
+ appState,
+ sensitivity = 1,
+}: StatsDragInputProps<T, E>) => {
+ const app = useApp();
+ const inputRef = useRef<HTMLInputElement>(null);
+ const labelRef = useRef<HTMLDivElement>(null);
+
+ const [inputValue, setInputValue] = useState(value.toString());
+
+ const stateRef = useRef<{
+ originalAppState: AppState;
+ originalElements: readonly E[];
+ lastUpdatedValue: string;
+ updatePending: boolean;
+ }>(null!);
+ if (!stateRef.current) {
+ stateRef.current = {
+ originalAppState: cloneJSON(appState),
+ originalElements: elements,
+ lastUpdatedValue: inputValue,
+ updatePending: false,
+ };
+ }
+
+ useEffect(() => {
+ const inputValue = value.toString();
+ setInputValue(inputValue);
+ stateRef.current.lastUpdatedValue = inputValue;
+ }, [value]);
+
+ const handleInputValue = (
+ updatedValue: string,
+ elements: readonly E[],
+ appState: AppState,
+ ) => {
+ if (!stateRef.current.updatePending) {
+ return false;
+ }
+ stateRef.current.updatePending = false;
+
+ const parsed = Number(updatedValue);
+ if (isNaN(parsed)) {
+ setInputValue(value.toString());
+ return;
+ }
+
+ const rounded = Number(parsed.toFixed(2));
+ const original = Number(value);
+
+ // only update when
+ // 1. original was "Mixed" and we have a new value
+ // 2. original was not "Mixed" and the difference between a new value and previous value is greater
+ // than the smallest delta allowed, which is 0.01
+ // reason: idempotent to avoid unnecessary
+ if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
+ stateRef.current.lastUpdatedValue = updatedValue;
+ dragInputCallback({
+ accumulatedChange: 0,
+ instantChange: 0,
+ originalElements: elements,
+ originalElementsMap: app.scene.getNonDeletedElementsMap(),
+ shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+ shouldChangeByStepSize: false,
+ scene,
+ nextValue: rounded,
+ property,
+ originalAppState: appState,
+ setInputValue: (value) => setInputValue(String(value)),
+ });
+ app.syncActionResult({
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ }
+ };
+
+ const callbacksRef = useRef<
+ Partial<{
+ handleInputValue: typeof handleInputValue;
+ onPointerUp: (event: PointerEvent) => void;
+ onPointerMove: (event: PointerEvent) => void;
+ }>
+ >({});
+ callbacksRef.current.handleInputValue = handleInputValue;
+
+ // make sure that clicking on canvas (which umounts the component)
+ // updates current input value (blur isn't triggered)
+ useEffect(() => {
+ const input = inputRef.current;
+ const callbacks = callbacksRef.current;
+ return () => {
+ const nextValue = input?.value;
+ if (nextValue) {
+ callbacks.handleInputValue?.(
+ nextValue,
+ stateRef.current.originalElements,
+ stateRef.current.originalAppState,
+ );
+ }
+
+ // generally not needed, but in case `pointerup` doesn't fire and
+ // we don't remove the listeners that way, we should at least remove
+ // on unmount
+ window.removeEventListener(
+ EVENT.POINTER_MOVE,
+ callbacks.onPointerMove!,
+ false,
+ );
+ window.removeEventListener(
+ EVENT.POINTER_UP,
+ callbacks.onPointerUp!,
+ false,
+ );
+ };
+ }, [
+ // 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,
+ ]);
+
+ if (!editable) {
+ return null;
+ }
+
+ return (
+ <div
+ className={clsx("drag-input-container", !editable && "disabled")}
+ data-testid={label}
+ >
+ <div
+ className="drag-input-label"
+ ref={labelRef}
+ onPointerDown={(event) => {
+ if (inputRef.current && editable) {
+ document.body.classList.add("excalidraw-cursor-resize");
+
+ let startValue = Number(inputRef.current.value);
+ if (isNaN(startValue)) {
+ startValue = 0;
+ }
+
+ let lastPointer: {
+ x: number;
+ y: number;
+ } | null = null;
+
+ let originalElementsMap: Map<string, ExcalidrawElement> | null =
+ app.scene
+ .getNonDeletedElements()
+ .reduce((acc: ElementsMap, element) => {
+ acc.set(element.id, deepCopyElement(element));
+ return acc;
+ }, new Map());
+
+ let originalElements: readonly E[] | null = elements.map(
+ (element) => originalElementsMap!.get(element.id) as E,
+ );
+
+ const originalAppState: AppState = cloneJSON(appState);
+
+ let accumulatedChange = 0;
+ let stepChange = 0;
+
+ const onPointerMove = (event: PointerEvent) => {
+ if (
+ lastPointer &&
+ originalElementsMap !== null &&
+ originalElements !== null
+ ) {
+ const instantChange = event.clientX - lastPointer.x;
+
+ if (instantChange !== 0) {
+ stepChange += instantChange;
+
+ if (Math.abs(stepChange) >= sensitivity) {
+ stepChange =
+ Math.sign(stepChange) *
+ Math.floor(Math.abs(stepChange) / sensitivity);
+
+ accumulatedChange += stepChange;
+
+ dragInputCallback({
+ accumulatedChange,
+ instantChange: stepChange,
+ originalElements,
+ originalElementsMap,
+ shouldKeepAspectRatio: shouldKeepAspectRatio!!,
+ shouldChangeByStepSize: event.shiftKey,
+ property,
+ scene,
+ originalAppState,
+ setInputValue: (value) => setInputValue(String(value)),
+ });
+
+ stepChange = 0;
+ }
+ }
+ }
+
+ lastPointer = {
+ x: event.clientX,
+ y: event.clientY,
+ };
+ };
+
+ const onPointerUp = () => {
+ window.removeEventListener(
+ EVENT.POINTER_MOVE,
+ onPointerMove,
+ false,
+ );
+
+ app.syncActionResult({
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+
+ lastPointer = null;
+ accumulatedChange = 0;
+ stepChange = 0;
+ originalElements = null;
+ originalElementsMap = null;
+
+ document.body.classList.remove("excalidraw-cursor-resize");
+
+ window.removeEventListener(EVENT.POINTER_UP, onPointerUp, false);
+ };
+
+ callbacksRef.current.onPointerMove = onPointerMove;
+ callbacksRef.current.onPointerUp = onPointerUp;
+
+ window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
+ window.addEventListener(EVENT.POINTER_UP, onPointerUp, false);
+ }
+ }}
+ onPointerEnter={() => {
+ if (labelRef.current) {
+ labelRef.current.style.cursor = "ew-resize";
+ }
+ }}
+ >
+ {icon ? <InlineIcon icon={icon} /> : label}
+ </div>
+ <input
+ className="drag-input"
+ autoComplete="off"
+ spellCheck="false"
+ onKeyDown={(event) => {
+ if (editable) {
+ const eventTarget = event.target;
+ if (
+ eventTarget instanceof HTMLInputElement &&
+ event.key === KEYS.ENTER
+ ) {
+ handleInputValue(eventTarget.value, elements, appState);
+ app.focusContainer();
+ }
+ }
+ }}
+ ref={inputRef}
+ value={inputValue}
+ onChange={(event) => {
+ stateRef.current.updatePending = true;
+ setInputValue(event.target.value);
+ }}
+ onFocus={(event) => {
+ event.target.select();
+ stateRef.current.originalElements = elements;
+ stateRef.current.originalAppState = cloneJSON(appState);
+ }}
+ onBlur={(event) => {
+ if (!inputValue) {
+ setInputValue(value.toString());
+ } else if (editable) {
+ handleInputValue(
+ event.target.value,
+ stateRef.current.originalElements,
+ stateRef.current.originalAppState,
+ );
+ }
+ }}
+ disabled={!editable}
+ />
+ </div>
+ );
+};
+
+export default StatsDragInput;
diff --git a/packages/excalidraw/components/Stats/FontSize.tsx b/packages/excalidraw/components/Stats/FontSize.tsx
new file mode 100644
index 0000000..13dc6db
--- /dev/null
+++ b/packages/excalidraw/components/Stats/FontSize.tsx
@@ -0,0 +1,99 @@
+import type {
+ ExcalidrawElement,
+ ExcalidrawTextElement,
+} from "../../element/types";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { mutateElement } from "../../element/mutateElement";
+import { getStepSizedValue } from "./utils";
+import { fontSizeIcon } from "../icons";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import { isTextElement, redrawTextBoundingBox } from "../../element";
+import { hasBoundTextElement } from "../../element/typeChecks";
+import { getBoundTextElement } from "../../element/textElement";
+
+interface FontSizeProps {
+ element: ExcalidrawElement;
+ scene: Scene;
+ appState: AppState;
+ property: "fontSize";
+}
+
+const MIN_FONT_SIZE = 4;
+const STEP_SIZE = 4;
+
+const handleFontSizeChange: DragInputCallbackType<
+ FontSizeProps["property"],
+ ExcalidrawTextElement
+> = ({
+ accumulatedChange,
+ originalElements,
+ shouldChangeByStepSize,
+ nextValue,
+ scene,
+}) => {
+ const elementsMap = scene.getNonDeletedElementsMap();
+
+ const origElement = originalElements[0];
+ if (origElement) {
+ const latestElement = elementsMap.get(origElement.id);
+ if (!latestElement || !isTextElement(latestElement)) {
+ return;
+ }
+
+ let nextFontSize;
+
+ if (nextValue !== undefined) {
+ nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
+ } else if (origElement.type === "text") {
+ const originalFontSize = Math.round(origElement.fontSize);
+ const changeInFontSize = Math.round(accumulatedChange);
+ nextFontSize = Math.max(
+ originalFontSize + changeInFontSize,
+ MIN_FONT_SIZE,
+ );
+ if (shouldChangeByStepSize) {
+ nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
+ }
+ }
+
+ if (nextFontSize) {
+ mutateElement(latestElement, {
+ fontSize: nextFontSize,
+ });
+ redrawTextBoundingBox(
+ latestElement,
+ scene.getContainerElement(latestElement),
+ scene.getNonDeletedElementsMap(),
+ );
+ }
+ }
+};
+
+const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
+ const _element = isTextElement(element)
+ ? element
+ : hasBoundTextElement(element)
+ ? getBoundTextElement(element, scene.getNonDeletedElementsMap())
+ : null;
+
+ if (!_element) {
+ return null;
+ }
+
+ return (
+ <StatsDragInput
+ label="F"
+ value={Math.round(_element.fontSize * 10) / 10}
+ elements={[_element]}
+ dragInputCallback={handleFontSizeChange}
+ icon={fontSizeIcon}
+ appState={appState}
+ scene={scene}
+ property={property}
+ />
+ );
+};
+
+export default FontSize;
diff --git a/packages/excalidraw/components/Stats/MultiAngle.tsx b/packages/excalidraw/components/Stats/MultiAngle.tsx
new file mode 100644
index 0000000..d9a7882
--- /dev/null
+++ b/packages/excalidraw/components/Stats/MultiAngle.tsx
@@ -0,0 +1,136 @@
+import { mutateElement } from "../../element/mutateElement";
+import { getBoundTextElement } from "../../element/textElement";
+import { isArrowElement } from "../../element/typeChecks";
+import type { ExcalidrawElement } from "../../element/types";
+import { isInGroup } from "../../groups";
+import type Scene from "../../scene/Scene";
+import { angleIcon } from "../icons";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, isPropertyEditable } from "./utils";
+import type { AppState } from "../../types";
+import type { Degrees } from "@excalidraw/math";
+import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
+
+interface MultiAngleProps {
+ elements: readonly ExcalidrawElement[];
+ scene: Scene;
+ appState: AppState;
+ property: "angle";
+}
+
+const STEP_SIZE = 15;
+
+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),
+ );
+
+ if (nextValue !== undefined) {
+ const nextAngle = degreesToRadians(nextValue as Degrees);
+
+ for (const element of editableLatestIndividualElements) {
+ if (!element) {
+ continue;
+ }
+ mutateElement(
+ element,
+ {
+ angle: nextAngle,
+ },
+ false,
+ );
+
+ const boundTextElement = getBoundTextElement(element, elementsMap);
+ if (boundTextElement && !isArrowElement(element)) {
+ mutateElement(boundTextElement, { angle: nextAngle }, false);
+ }
+ }
+
+ 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(radiansToDegrees(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 = degreesToRadians(nextAngleInDegrees as Degrees);
+
+ 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(
+ (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
+ );
+ const angles = editableLatestIndividualElements.map(
+ (el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
+ );
+ const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
+
+ const editable = editableLatestIndividualElements.some((el) =>
+ isPropertyEditable(el, "angle"),
+ );
+
+ return (
+ <DragInput
+ label="A"
+ icon={angleIcon}
+ value={value}
+ elements={elements}
+ dragInputCallback={handleDegreeChange}
+ editable={editable}
+ appState={appState}
+ scene={scene}
+ property={property}
+ />
+ );
+};
+
+export default MultiAngle;
diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx
new file mode 100644
index 0000000..04f1937
--- /dev/null
+++ b/packages/excalidraw/components/Stats/MultiDimension.tsx
@@ -0,0 +1,401 @@
+import { useMemo } from "react";
+import { getCommonBounds, isTextElement } from "../../element";
+import { updateBoundElements } from "../../element/binding";
+import { mutateElement } from "../../element/mutateElement";
+import {
+ rescalePointsInElement,
+ resizeSingleElement,
+} from "../../element/resizeElements";
+import {
+ getBoundTextElement,
+ handleBindTextResize,
+} from "../../element/textElement";
+import type {
+ ElementsMap,
+ ExcalidrawElement,
+ NonDeletedSceneElementsMap,
+} from "../../element/types";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import DragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
+import { getElementsInAtomicUnit } from "./utils";
+import type { AtomicUnit } from "./utils";
+import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
+import { pointFrom, type GlobalPoint } from "@excalidraw/math";
+
+interface MultiDimensionProps {
+ property: "width" | "height";
+ elements: readonly ExcalidrawElement[];
+ elementsMap: NonDeletedSceneElementsMap;
+ atomicUnits: AtomicUnit[];
+ scene: Scene;
+ appState: AppState;
+}
+
+const STEP_SIZE = 10;
+
+const getResizedUpdates = (
+ anchorX: number,
+ anchorY: number,
+ scale: number,
+ origElement: ExcalidrawElement,
+) => {
+ const offsetX = origElement.x - anchorX;
+ const offsetY = origElement.y - anchorY;
+ const nextWidth = origElement.width * scale;
+ const nextHeight = origElement.height * scale;
+ const x = anchorX + offsetX * scale;
+ const y = anchorY + offsetY * scale;
+
+ return {
+ width: nextWidth,
+ height: nextHeight,
+ x,
+ y,
+ ...rescalePointsInElement(origElement, nextWidth, nextHeight, false),
+ ...(isTextElement(origElement)
+ ? { fontSize: origElement.fontSize * scale }
+ : {}),
+ };
+};
+
+const resizeElementInGroup = (
+ anchorX: number,
+ anchorY: number,
+ property: MultiDimensionProps["property"],
+ scale: number,
+ latestElement: ExcalidrawElement,
+ origElement: ExcalidrawElement,
+ elementsMap: NonDeletedSceneElementsMap,
+ originalElementsMap: ElementsMap,
+) => {
+ const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
+
+ mutateElement(latestElement, updates, false);
+ const boundTextElement = getBoundTextElement(
+ origElement,
+ originalElementsMap,
+ );
+ if (boundTextElement) {
+ const newFontSize = boundTextElement.fontSize * scale;
+ updateBoundElements(latestElement, elementsMap, {
+ newSize: { width: updates.width, height: updates.height },
+ });
+ const latestBoundTextElement = elementsMap.get(boundTextElement.id);
+ if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
+ mutateElement(
+ latestBoundTextElement,
+ {
+ fontSize: newFontSize,
+ },
+ false,
+ );
+ handleBindTextResize(
+ latestElement,
+ elementsMap,
+ property === "width" ? "e" : "s",
+ true,
+ );
+ }
+ }
+};
+
+const resizeGroup = (
+ nextWidth: number,
+ nextHeight: number,
+ initialHeight: number,
+ aspectRatio: number,
+ anchor: GlobalPoint,
+ property: MultiDimensionProps["property"],
+ latestElements: ExcalidrawElement[],
+ originalElements: ExcalidrawElement[],
+ elementsMap: NonDeletedSceneElementsMap,
+ originalElementsMap: ElementsMap,
+) => {
+ // keep aspect ratio for groups
+ if (property === "width") {
+ nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
+ } else {
+ nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
+ }
+
+ const scale = nextHeight / initialHeight;
+
+ for (let i = 0; i < originalElements.length; i++) {
+ const origElement = originalElements[i];
+ const latestElement = latestElements[i];
+
+ resizeElementInGroup(
+ anchor[0],
+ anchor[1],
+ property,
+ scale,
+ latestElement,
+ origElement,
+ elementsMap,
+ originalElementsMap,
+ );
+ }
+};
+
+const handleDimensionChange: DragInputCallbackType<
+ MultiDimensionProps["property"]
+> = ({
+ accumulatedChange,
+ originalElements,
+ originalElementsMap,
+ originalAppState,
+ shouldChangeByStepSize,
+ nextValue,
+ scene,
+ property,
+}) => {
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const atomicUnits = getAtomicUnits(originalElements, originalAppState);
+ 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,
+ pointFrom(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);
+
+ resizeSingleElement(
+ nextWidth,
+ nextHeight,
+ latestElement,
+ origElement,
+ elementsMap,
+ originalElementsMap,
+ property === "width" ? "e" : "s",
+ {
+ shouldInformMutation: false,
+ },
+ );
+ }
+ }
+ }
+
+ 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,
+ pointFrom(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);
+
+ resizeSingleElement(
+ nextWidth,
+ nextHeight,
+ latestElement,
+ origElement,
+ elementsMap,
+ originalElementsMap,
+ property === "width" ? "e" : "s",
+ {
+ shouldInformMutation: false,
+ },
+ );
+ }
+ }
+ }
+
+ 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 (
+ <DragInput
+ label={property === "width" ? "W" : "H"}
+ elements={elements}
+ dragInputCallback={handleDimensionChange}
+ value={value}
+ editable={editable}
+ appState={appState}
+ property={property}
+ scene={scene}
+ />
+ );
+};
+
+export default MultiDimension;
diff --git a/packages/excalidraw/components/Stats/MultiFontSize.tsx b/packages/excalidraw/components/Stats/MultiFontSize.tsx
new file mode 100644
index 0000000..419bbbc
--- /dev/null
+++ b/packages/excalidraw/components/Stats/MultiFontSize.tsx
@@ -0,0 +1,164 @@
+import { isTextElement, redrawTextBoundingBox } from "../../element";
+import { mutateElement } from "../../element/mutateElement";
+import { hasBoundTextElement } from "../../element/typeChecks";
+import type {
+ ExcalidrawElement,
+ ExcalidrawTextElement,
+ NonDeletedSceneElementsMap,
+} from "../../element/types";
+import { isInGroup } from "../../groups";
+import type Scene from "../../scene/Scene";
+import { fontSizeIcon } from "../icons";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue } from "./utils";
+import type { AppState } from "../../types";
+import { getBoundTextElement } from "../../element/textElement";
+
+interface MultiFontSizeProps {
+ elements: readonly ExcalidrawElement[];
+ scene: Scene;
+ elementsMap: NonDeletedSceneElementsMap;
+ appState: AppState;
+ property: "fontSize";
+}
+
+const MIN_FONT_SIZE = 4;
+const STEP_SIZE = 4;
+
+const getApplicableTextElements = (
+ elements: readonly (ExcalidrawElement | undefined)[],
+ elementsMap: NonDeletedSceneElementsMap,
+) =>
+ elements.reduce(
+ (acc: ExcalidrawTextElement[], el) => {
+ if (!el || isInGroup(el)) {
+ return acc;
+ }
+ if (isTextElement(el)) {
+ acc.push(el);
+ return acc;
+ }
+ if (hasBoundTextElement(el)) {
+ const boundTextElement = getBoundTextElement(el, elementsMap);
+ if (boundTextElement) {
+ acc.push(boundTextElement);
+ return acc;
+ }
+ }
+
+ return acc;
+ },
+
+ [],
+ );
+
+const handleFontSizeChange: DragInputCallbackType<
+ MultiFontSizeProps["property"],
+ ExcalidrawTextElement
+> = ({
+ accumulatedChange,
+ originalElements,
+ shouldChangeByStepSize,
+ nextValue,
+ scene,
+}) => {
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const latestTextElements = originalElements.map((el) =>
+ elementsMap.get(el.id),
+ ) as ExcalidrawTextElement[];
+
+ let nextFontSize;
+
+ if (nextValue) {
+ nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
+
+ for (const textElement of latestTextElements) {
+ mutateElement(
+ textElement,
+ {
+ fontSize: nextFontSize,
+ },
+ false,
+ );
+
+ redrawTextBoundingBox(
+ textElement,
+ scene.getContainerElement(textElement),
+ elementsMap,
+ false,
+ );
+ }
+
+ scene.triggerUpdate();
+ } else {
+ const originalTextElements = originalElements 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);
+ }
+ mutateElement(
+ latestElement,
+ {
+ fontSize: nextFontSize,
+ },
+ false,
+ );
+
+ redrawTextBoundingBox(
+ latestElement,
+ scene.getContainerElement(latestElement),
+ elementsMap,
+ false,
+ );
+ }
+
+ scene.triggerUpdate();
+ }
+};
+
+const MultiFontSize = ({
+ elements,
+ scene,
+ appState,
+ property,
+ elementsMap,
+}: MultiFontSizeProps) => {
+ const latestTextElements = getApplicableTextElements(elements, elementsMap);
+
+ if (!latestTextElements.length) {
+ return null;
+ }
+
+ 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 (
+ <StatsDragInput
+ label="F"
+ icon={fontSizeIcon}
+ elements={latestTextElements}
+ dragInputCallback={handleFontSizeChange}
+ value={value}
+ editable={editable}
+ scene={scene}
+ property={property}
+ appState={appState}
+ />
+ );
+};
+
+export default MultiFontSize;
diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx
new file mode 100644
index 0000000..8a29aa6
--- /dev/null
+++ b/packages/excalidraw/components/Stats/MultiPosition.tsx
@@ -0,0 +1,270 @@
+import type {
+ ElementsMap,
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+ NonDeletedSceneElementsMap,
+} from "../../element/types";
+import type Scene from "../../scene/Scene";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
+import { getCommonBounds, isTextElement } from "../../element";
+import { useMemo } from "react";
+import { getElementsInAtomicUnit, moveElement } from "./utils";
+import type { AtomicUnit } from "./utils";
+import type { AppState } from "../../types";
+import { pointFrom, pointRotateRads } from "@excalidraw/math";
+
+interface MultiPositionProps {
+ property: "x" | "y";
+ elements: readonly ExcalidrawElement[];
+ elementsMap: ElementsMap;
+ atomicUnits: AtomicUnit[];
+ scene: Scene;
+ appState: AppState;
+}
+
+const STEP_SIZE = 10;
+
+const moveElements = (
+ property: MultiPositionProps["property"],
+ changeInTopX: number,
+ changeInTopY: number,
+ elements: readonly ExcalidrawElement[],
+ originalElements: readonly ExcalidrawElement[],
+ elementsMap: NonDeletedSceneElementsMap,
+ originalElementsMap: ElementsMap,
+ scene: Scene,
+) => {
+ for (let i = 0; i < elements.length; i++) {
+ const origElement = originalElements[i];
+
+ const [cx, cy] = [
+ origElement.x + origElement.width / 2,
+ origElement.y + origElement.height / 2,
+ ];
+ const [topLeftX, topLeftY] = pointRotateRads(
+ pointFrom(origElement.x, origElement.y),
+ pointFrom(cx, cy),
+ origElement.angle,
+ );
+
+ const newTopLeftX =
+ property === "x" ? Math.round(topLeftX + changeInTopX) : topLeftX;
+
+ const newTopLeftY =
+ property === "y" ? Math.round(topLeftY + changeInTopY) : topLeftY;
+
+ moveElement(
+ newTopLeftX,
+ newTopLeftY,
+ origElement,
+ elementsMap,
+ elements,
+ scene,
+ originalElementsMap,
+ false,
+ );
+ }
+};
+
+const moveGroupTo = (
+ nextX: number,
+ nextY: number,
+ originalElements: ExcalidrawElement[],
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+ originalElementsMap: ElementsMap,
+ scene: Scene,
+) => {
+ const [x1, y1, ,] = getCommonBounds(originalElements);
+ const offsetX = nextX - x1;
+ const offsetY = nextY - y1;
+
+ for (let i = 0; i < originalElements.length; i++) {
+ const origElement = originalElements[i];
+
+ const latestElement = elementsMap.get(origElement.id);
+ if (!latestElement) {
+ continue;
+ }
+
+ // bound texts are moved with their containers
+ if (!isTextElement(latestElement) || !latestElement.containerId) {
+ const [cx, cy] = [
+ latestElement.x + latestElement.width / 2,
+ latestElement.y + latestElement.height / 2,
+ ];
+
+ const [topLeftX, topLeftY] = pointRotateRads(
+ pointFrom(latestElement.x, latestElement.y),
+ pointFrom(cx, cy),
+ latestElement.angle,
+ );
+
+ moveElement(
+ topLeftX + offsetX,
+ topLeftY + offsetY,
+ origElement,
+ elementsMap,
+ elements,
+ scene,
+ originalElementsMap,
+ false,
+ );
+ }
+ }
+};
+
+const handlePositionChange: DragInputCallbackType<
+ MultiPositionProps["property"]
+> = ({
+ accumulatedChange,
+ originalElements,
+ originalElementsMap,
+ shouldChangeByStepSize,
+ nextValue,
+ property,
+ scene,
+ originalAppState,
+}) => {
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const elements = scene.getNonDeletedElements();
+
+ 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,
+ elements,
+ originalElementsMap,
+ scene,
+ );
+ } 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] = pointRotateRads(
+ pointFrom(origElement.x, origElement.y),
+ pointFrom(cx, cy),
+ origElement.angle,
+ );
+
+ const newTopLeftX = property === "x" ? nextValue : topLeftX;
+ const newTopLeftY = property === "y" ? nextValue : topLeftY;
+ moveElement(
+ newTopLeftX,
+ newTopLeftY,
+ origElement,
+ elementsMap,
+ elements,
+ scene,
+ 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,
+ );
+
+ scene.triggerUpdate();
+};
+
+const MultiPosition = ({
+ property,
+ elements,
+ elementsMap,
+ atomicUnits,
+ scene,
+ appState,
+}: MultiPositionProps) => {
+ const positions = useMemo(
+ () =>
+ atomicUnits.map((atomicUnit) => {
+ const elementsInUnit = Object.keys(atomicUnit)
+ .map((id) => elementsMap.get(id))
+ .filter((el) => el !== undefined) as ExcalidrawElement[];
+
+ // we're dealing with a group
+ if (elementsInUnit.length > 1) {
+ const [x1, y1] = getCommonBounds(elementsInUnit);
+ return Math.round((property === "x" ? x1 : y1) * 100) / 100;
+ }
+
+ const [el] = elementsInUnit;
+ const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
+
+ const [topLeftX, topLeftY] = pointRotateRads(
+ pointFrom(el.x, el.y),
+ pointFrom(cx, cy),
+ el.angle,
+ );
+
+ return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
+ }),
+ [atomicUnits, elementsMap, property],
+ );
+
+ const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
+
+ return (
+ <StatsDragInput
+ label={property === "x" ? "X" : "Y"}
+ elements={elements}
+ dragInputCallback={handlePositionChange}
+ value={value}
+ property={property}
+ scene={scene}
+ appState={appState}
+ />
+ );
+};
+
+export default MultiPosition;
diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx
new file mode 100644
index 0000000..038e58e
--- /dev/null
+++ b/packages/excalidraw/components/Stats/Position.tsx
@@ -0,0 +1,214 @@
+import type { ElementsMap, ExcalidrawElement } from "../../element/types";
+import StatsDragInput from "./DragInput";
+import type { DragInputCallbackType } from "./DragInput";
+import { getStepSizedValue, moveElement } from "./utils";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+import { clamp, pointFrom, pointRotateRads, round } from "@excalidraw/math";
+import { isImageElement } from "../../element/typeChecks";
+import {
+ getFlipAdjustedCropPosition,
+ getUncroppedWidthAndHeight,
+} from "../../element/cropElement";
+import { mutateElement } from "../../element/mutateElement";
+
+interface PositionProps {
+ property: "x" | "y";
+ element: ExcalidrawElement;
+ elementsMap: ElementsMap;
+ scene: Scene;
+ appState: AppState;
+}
+
+const STEP_SIZE = 10;
+
+const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
+ accumulatedChange,
+ instantChange,
+ originalElements,
+ originalElementsMap,
+ shouldChangeByStepSize,
+ nextValue,
+ property,
+ scene,
+ originalAppState,
+}) => {
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const elements = scene.getNonDeletedElements();
+ const origElement = originalElements[0];
+ const [cx, cy] = [
+ origElement.x + origElement.width / 2,
+ origElement.y + origElement.height / 2,
+ ];
+ const [topLeftX, topLeftY] = pointRotateRads(
+ pointFrom(origElement.x, origElement.y),
+ pointFrom(cx, cy),
+ origElement.angle,
+ );
+
+ if (originalAppState.croppingElementId === origElement.id) {
+ const element = elementsMap.get(origElement.id);
+
+ if (!element || !isImageElement(element) || !element.crop) {
+ return;
+ }
+
+ const crop = element.crop;
+ let nextCrop = crop;
+ const isFlippedByX = element.scale[0] === -1;
+ const isFlippedByY = element.scale[1] === -1;
+ const { width: uncroppedWidth, height: uncroppedHeight } =
+ getUncroppedWidthAndHeight(element);
+
+ if (nextValue !== undefined) {
+ if (property === "x") {
+ const nextValueInNatural =
+ nextValue * (crop.naturalWidth / uncroppedWidth);
+
+ if (isFlippedByX) {
+ nextCrop = {
+ ...crop,
+ x: clamp(
+ crop.naturalWidth - nextValueInNatural - crop.width,
+ 0,
+ crop.naturalWidth - crop.width,
+ ),
+ };
+ } else {
+ nextCrop = {
+ ...crop,
+ x: clamp(
+ nextValue * (crop.naturalWidth / uncroppedWidth),
+ 0,
+ crop.naturalWidth - crop.width,
+ ),
+ };
+ }
+ }
+
+ if (property === "y") {
+ nextCrop = {
+ ...crop,
+ y: clamp(
+ nextValue * (crop.naturalHeight / uncroppedHeight),
+ 0,
+ crop.naturalHeight - crop.height,
+ ),
+ };
+ }
+
+ mutateElement(element, {
+ crop: nextCrop,
+ });
+
+ return;
+ }
+
+ const changeInX =
+ (property === "x" ? instantChange : 0) * (isFlippedByX ? -1 : 1);
+ const changeInY =
+ (property === "y" ? instantChange : 0) * (isFlippedByY ? -1 : 1);
+
+ nextCrop = {
+ ...crop,
+ x: clamp(crop.x + changeInX, 0, crop.naturalWidth - crop.width),
+ y: clamp(crop.y + changeInY, 0, crop.naturalHeight - crop.height),
+ };
+
+ mutateElement(element, {
+ crop: nextCrop,
+ });
+
+ return;
+ }
+
+ if (nextValue !== undefined) {
+ const newTopLeftX = property === "x" ? nextValue : topLeftX;
+ const newTopLeftY = property === "y" ? nextValue : topLeftY;
+ moveElement(
+ newTopLeftX,
+ newTopLeftY,
+ origElement,
+ elementsMap,
+ elements,
+ scene,
+ 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,
+ elements,
+ scene,
+ originalElementsMap,
+ );
+};
+
+const Position = ({
+ property,
+ element,
+ elementsMap,
+ scene,
+ appState,
+}: PositionProps) => {
+ const [topLeftX, topLeftY] = pointRotateRads(
+ pointFrom(element.x, element.y),
+ pointFrom(element.x + element.width / 2, element.y + element.height / 2),
+ element.angle,
+ );
+ let value = round(property === "x" ? topLeftX : topLeftY, 2);
+
+ if (
+ appState.croppingElementId === element.id &&
+ isImageElement(element) &&
+ element.crop
+ ) {
+ const flipAdjustedPosition = getFlipAdjustedCropPosition(element);
+
+ if (flipAdjustedPosition) {
+ value = round(
+ property === "x" ? flipAdjustedPosition.x : flipAdjustedPosition.y,
+ 2,
+ );
+ }
+ }
+
+ return (
+ <StatsDragInput
+ label={property === "x" ? "X" : "Y"}
+ elements={[element]}
+ dragInputCallback={handlePositionChange}
+ scene={scene}
+ value={value}
+ property={property}
+ appState={appState}
+ />
+ );
+};
+
+export default Position;
diff --git a/packages/excalidraw/components/Stats/Stats.scss b/packages/excalidraw/components/Stats/Stats.scss
new file mode 100644
index 0000000..106ecf3
--- /dev/null
+++ b/packages/excalidraw/components/Stats/Stats.scss
@@ -0,0 +1,72 @@
+.exc-stats {
+ width: 204px;
+ position: absolute;
+ top: 60px;
+ font-size: 12px;
+ z-index: var(--zIndex-layerUI);
+ pointer-events: var(--ui-pointerEvents);
+
+ :root[dir="rtl"] & {
+ left: 12px;
+ right: initial;
+ }
+
+ h2 {
+ font-size: 1.5em;
+ margin-block-start: 0.83em;
+ margin-block-end: 0.83em;
+ font-weight: bold;
+ }
+ h3 {
+ white-space: nowrap;
+ font-size: 1.17em;
+ margin: 0;
+ font-weight: bold;
+ }
+
+ &__rows {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3125rem;
+ }
+
+ &__row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ display: grid;
+ gap: 4px;
+
+ div + div {
+ text-align: right;
+ }
+ }
+
+ &__row--heading {
+ text-align: center;
+ font-weight: bold;
+ margin: 0.25rem 0;
+ }
+
+ .title {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 12px;
+
+ h2 {
+ margin: 0;
+ }
+ }
+
+ .close {
+ height: 16px;
+ width: 16px;
+ cursor: pointer;
+ svg {
+ width: 100%;
+ height: 100%;
+ }
+ }
+}
diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx
new file mode 100644
index 0000000..2ccd93c
--- /dev/null
+++ b/packages/excalidraw/components/Stats/index.tsx
@@ -0,0 +1,434 @@
+import { useEffect, useMemo, useState, memo } from "react";
+import { getCommonBounds } from "../../element/bounds";
+import type { NonDeletedExcalidrawElement } from "../../element/types";
+import { t } from "../../i18n";
+import type {
+ AppClassProperties,
+ AppState,
+ ExcalidrawProps,
+} from "../../types";
+import { CloseIcon } from "../icons";
+import { Island } from "../Island";
+import throttle from "lodash.throttle";
+import Dimension from "./Dimension";
+import Angle from "./Angle";
+import FontSize from "./FontSize";
+import MultiDimension from "./MultiDimension";
+import { elementsAreInSameGroup } from "../../groups";
+import MultiAngle from "./MultiAngle";
+import MultiFontSize from "./MultiFontSize";
+import Position from "./Position";
+import MultiPosition from "./MultiPosition";
+import Collapsible from "./Collapsible";
+import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
+import { getAtomicUnits } from "./utils";
+import { STATS_PANELS } from "../../constants";
+import { isElbowArrow, isImageElement } from "../../element/typeChecks";
+import CanvasGrid from "./CanvasGrid";
+import clsx from "clsx";
+
+import "./Stats.scss";
+import { isGridModeEnabled } from "../../snapping";
+import { getUncroppedWidthAndHeight } from "../../element/cropElement";
+import { round } from "@excalidraw/math";
+import { frameAndChildrenSelectedTogether } from "../../frame";
+
+interface StatsProps {
+ app: AppClassProperties;
+ onClose: () => void;
+ renderCustomStats: ExcalidrawProps["renderCustomStats"];
+}
+
+const STATS_TIMEOUT = 50;
+
+export const Stats = (props: StatsProps) => {
+ const appState = useExcalidrawAppState();
+ const sceneNonce = props.app.scene.getSceneNonce() || 1;
+ const selectedElements = props.app.scene.getSelectedElements({
+ selectedElementIds: appState.selectedElementIds,
+ includeBoundTextElement: false,
+ });
+ const gridModeEnabled = isGridModeEnabled(props.app);
+
+ return (
+ <StatsInner
+ {...props}
+ appState={appState}
+ sceneNonce={sceneNonce}
+ selectedElements={selectedElements}
+ gridModeEnabled={gridModeEnabled}
+ />
+ );
+};
+
+const StatsRow = ({
+ children,
+ columns = 1,
+ heading,
+ style,
+ ...rest
+}: {
+ children: React.ReactNode;
+ columns?: number;
+ heading?: boolean;
+ style?: React.CSSProperties;
+} & React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={clsx("exc-stats__row", { "exc-stats__row--heading": heading })}
+ style={{
+ gridTemplateColumns: `repeat(${columns}, 1fr)`,
+ ...style,
+ }}
+ {...rest}
+ >
+ {children}
+ </div>
+);
+StatsRow.displayName = "StatsRow";
+
+const StatsRows = ({
+ children,
+ order,
+ style,
+ ...rest
+}: {
+ children: React.ReactNode;
+ order?: number;
+ style?: React.CSSProperties;
+} & React.HTMLAttributes<HTMLDivElement>) => (
+ <div className="exc-stats__rows" style={{ order, ...style }} {...rest}>
+ {children}
+ </div>
+);
+StatsRows.displayName = "StatsRows";
+
+Stats.StatsRow = StatsRow;
+Stats.StatsRows = StatsRows;
+
+export const StatsInner = memo(
+ ({
+ app,
+ onClose,
+ renderCustomStats,
+ selectedElements,
+ appState,
+ sceneNonce,
+ gridModeEnabled,
+ }: StatsProps & {
+ sceneNonce: number;
+ selectedElements: readonly NonDeletedExcalidrawElement[];
+ appState: AppState;
+ gridModeEnabled: boolean;
+ }) => {
+ const scene = app.scene;
+ const elements = scene.getNonDeletedElements();
+ const elementsMap = scene.getNonDeletedElementsMap();
+ const setAppState = useExcalidrawSetAppState();
+
+ const singleElement =
+ selectedElements.length === 1 ? selectedElements[0] : null;
+
+ const multipleElements =
+ selectedElements.length > 1 ? selectedElements : null;
+
+ const cropMode =
+ appState.croppingElementId && isImageElement(singleElement);
+
+ const unCroppedDimension = cropMode
+ ? getUncroppedWidthAndHeight(singleElement)
+ : null;
+
+ const [sceneDimension, setSceneDimension] = useState<{
+ width: number;
+ height: number;
+ }>({
+ width: 0,
+ height: 0,
+ });
+
+ const throttledSetSceneDimension = useMemo(
+ () =>
+ throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
+ const boundingBox = getCommonBounds(elements);
+ setSceneDimension({
+ width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
+ height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
+ });
+ }, STATS_TIMEOUT),
+ [],
+ );
+
+ useEffect(() => {
+ throttledSetSceneDimension(elements);
+ }, [sceneNonce, elements, throttledSetSceneDimension]);
+
+ useEffect(
+ () => () => throttledSetSceneDimension.cancel(),
+ [throttledSetSceneDimension],
+ );
+
+ const atomicUnits = useMemo(() => {
+ return getAtomicUnits(selectedElements, appState);
+ }, [selectedElements, appState]);
+
+ const _frameAndChildrenSelectedTogether = useMemo(() => {
+ return frameAndChildrenSelectedTogether(selectedElements);
+ }, [selectedElements]);
+
+ return (
+ <div className="exc-stats">
+ <Island padding={3}>
+ <div className="title">
+ <h2>{t("stats.title")}</h2>
+ <div className="close" onClick={onClose}>
+ {CloseIcon}
+ </div>
+ </div>
+
+ <Collapsible
+ label={<h3>{t("stats.generalStats")}</h3>}
+ open={!!(appState.stats.panels & STATS_PANELS.generalStats)}
+ openTrigger={() =>
+ setAppState((state) => {
+ return {
+ stats: {
+ open: true,
+ panels: state.stats.panels ^ STATS_PANELS.generalStats,
+ },
+ };
+ })
+ }
+ >
+ <StatsRows>
+ <StatsRow heading>{t("stats.scene")}</StatsRow>
+ <StatsRow columns={2}>
+ <div>{t("stats.shapes")}</div>
+ <div>{elements.length}</div>
+ </StatsRow>
+ <StatsRow columns={2}>
+ <div>{t("stats.width")}</div>
+ <div>{sceneDimension.width}</div>
+ </StatsRow>
+ <StatsRow columns={2}>
+ <div>{t("stats.height")}</div>
+ <div>{sceneDimension.height}</div>
+ </StatsRow>
+ {gridModeEnabled && (
+ <>
+ <StatsRow heading>Canvas</StatsRow>
+ <StatsRow>
+ <CanvasGrid
+ property="gridStep"
+ scene={scene}
+ appState={appState}
+ setAppState={setAppState}
+ />
+ </StatsRow>
+ </>
+ )}
+ </StatsRows>
+
+ {renderCustomStats?.(elements, appState)}
+ </Collapsible>
+
+ {!_frameAndChildrenSelectedTogether && selectedElements.length > 0 && (
+ <div
+ id="elementStats"
+ style={{
+ marginTop: 12,
+ }}
+ >
+ <Collapsible
+ label={<h3>{t("stats.elementProperties")}</h3>}
+ open={
+ !!(appState.stats.panels & STATS_PANELS.elementProperties)
+ }
+ openTrigger={() =>
+ setAppState((state) => {
+ return {
+ stats: {
+ open: true,
+ panels:
+ state.stats.panels ^ STATS_PANELS.elementProperties,
+ },
+ };
+ })
+ }
+ >
+ <StatsRows>
+ {singleElement && (
+ <>
+ {cropMode && (
+ <StatsRow heading>
+ {t("labels.unCroppedDimension")}
+ </StatsRow>
+ )}
+
+ {appState.croppingElementId &&
+ isImageElement(singleElement) &&
+ unCroppedDimension && (
+ <StatsRow columns={2}>
+ <div>{t("stats.width")}</div>
+ <div>{round(unCroppedDimension.width, 2)}</div>
+ </StatsRow>
+ )}
+
+ {appState.croppingElementId &&
+ isImageElement(singleElement) &&
+ unCroppedDimension && (
+ <StatsRow columns={2}>
+ <div>{t("stats.height")}</div>
+ <div>{round(unCroppedDimension.height, 2)}</div>
+ </StatsRow>
+ )}
+
+ <StatsRow heading data-testid="stats-element-type">
+ {appState.croppingElementId
+ ? t("labels.imageCropping")
+ : t(`element.${singleElement.type}`)}
+ </StatsRow>
+
+ <StatsRow>
+ <Position
+ element={singleElement}
+ property="x"
+ elementsMap={elementsMap}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <Position
+ element={singleElement}
+ property="y"
+ elementsMap={elementsMap}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <Dimension
+ property="width"
+ element={singleElement}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <Dimension
+ property="height"
+ element={singleElement}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ {!isElbowArrow(singleElement) && (
+ <StatsRow>
+ <Angle
+ property="angle"
+ element={singleElement}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ )}
+ <StatsRow>
+ <FontSize
+ property="fontSize"
+ element={singleElement}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ </>
+ )}
+
+ {multipleElements && (
+ <>
+ {elementsAreInSameGroup(multipleElements) && (
+ <StatsRow heading>{t("element.group")}</StatsRow>
+ )}
+
+ <StatsRow columns={2} style={{ margin: "0.3125rem 0" }}>
+ <div>{t("stats.shapes")}</div>
+ <div>{selectedElements.length}</div>
+ </StatsRow>
+
+ <StatsRow>
+ <MultiPosition
+ property="x"
+ elements={multipleElements}
+ elementsMap={elementsMap}
+ atomicUnits={atomicUnits}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiPosition
+ property="y"
+ elements={multipleElements}
+ elementsMap={elementsMap}
+ atomicUnits={atomicUnits}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiDimension
+ property="width"
+ elements={multipleElements}
+ elementsMap={elementsMap}
+ atomicUnits={atomicUnits}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiDimension
+ property="height"
+ elements={multipleElements}
+ elementsMap={elementsMap}
+ atomicUnits={atomicUnits}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiAngle
+ property="angle"
+ elements={multipleElements}
+ scene={scene}
+ appState={appState}
+ />
+ </StatsRow>
+ <StatsRow>
+ <MultiFontSize
+ property="fontSize"
+ elements={multipleElements}
+ scene={scene}
+ appState={appState}
+ elementsMap={elementsMap}
+ />
+ </StatsRow>
+ </>
+ )}
+ </StatsRows>
+ </Collapsible>
+ </div>
+ )}
+ </Island>
+ </div>
+ );
+ },
+ (prev, next) => {
+ return (
+ prev.sceneNonce === next.sceneNonce &&
+ prev.selectedElements === next.selectedElements &&
+ prev.appState.stats.panels === next.appState.stats.panels &&
+ prev.gridModeEnabled === next.gridModeEnabled &&
+ prev.appState.gridStep === next.appState.gridStep &&
+ prev.appState.croppingElementId === next.appState.croppingElementId
+ );
+ },
+);
diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx
new file mode 100644
index 0000000..49d6a62
--- /dev/null
+++ b/packages/excalidraw/components/Stats/stats.test.tsx
@@ -0,0 +1,724 @@
+import React from "react";
+import { act, fireEvent, queryByTestId } from "@testing-library/react";
+import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
+import { getStepSizedValue } from "./utils";
+import {
+ GlobalTestState,
+ mockBoundingClientRect,
+ render,
+ restoreOriginalGetBoundingClientRect,
+} from "../../tests/test-utils";
+import * as StaticScene from "../../renderer/staticScene";
+import { vi } from "vitest";
+import { reseed } from "../../random";
+import { setDateTimeForTests } from "../../utils";
+import { Excalidraw, mutateElement } from "../..";
+import { t } from "../../i18n";
+import type {
+ ExcalidrawElement,
+ ExcalidrawLinearElement,
+ ExcalidrawTextElement,
+} from "../../element/types";
+import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
+import { getCommonBounds, isTextElement } from "../../element";
+import { API } from "../../tests/helpers/api";
+import { actionGroup } from "../../actions";
+import { isInGroup } from "../../groups";
+import type { Degrees } from "@excalidraw/math";
+import { degreesToRadians, pointFrom, pointRotateRads } from "@excalidraw/math";
+
+const { h } = window;
+const mouse = new Pointer("mouse");
+const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
+let stats: HTMLElement | null = null;
+let elementStats: HTMLElement | null | undefined = null;
+
+const testInputProperty = (
+ element: ExcalidrawElement,
+ property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
+ label: string,
+ initialValue: number,
+ nextValue: number,
+) => {
+ const input = UI.queryStatsProperty(label)?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(input).toBeDefined();
+ expect(input.value).toBe(initialValue.toString());
+ UI.updateInput(input, String(nextValue));
+ if (property === "angle") {
+ expect(element[property]).toBe(
+ degreesToRadians(Number(nextValue) as Degrees),
+ );
+ } else if (property === "fontSize" && isTextElement(element)) {
+ expect(element[property]).toBe(Number(nextValue));
+ } else if (property !== "fontSize") {
+ expect(element[property]).toBe(Number(nextValue));
+ }
+};
+
+describe("step sized value", () => {
+ it("should return edge values correctly", () => {
+ const steps = [10, 15, 20, 25, 30];
+ const values = [10, 15, 20, 25, 30];
+
+ steps.forEach((step, idx) => {
+ expect(getStepSizedValue(values[idx], step)).toEqual(values[idx]);
+ });
+ });
+
+ it("step sized value lies in the middle", () => {
+ let stepSize = 15;
+ let values = [7.5, 9, 12, 14.99, 15, 22.49];
+
+ values.forEach((value) => {
+ expect(getStepSizedValue(value, stepSize)).toEqual(15);
+ });
+
+ stepSize = 10;
+ values = [-5, 4.99, 0, 1.23];
+ values.forEach((value) => {
+ expect(getStepSizedValue(value, stepSize)).toEqual(0);
+ });
+ });
+});
+
+describe("binding with linear elements", () => {
+ beforeEach(async () => {
+ localStorage.clear();
+ renderStaticScene.mockClear();
+ reseed(19);
+ setDateTimeForTests("201933152653");
+
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+ API.setElements([]);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 1,
+ clientY: 1,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+ stats = UI.queryStats();
+
+ UI.clickTool("rectangle");
+ mouse.down();
+ mouse.up(200, 100);
+
+ UI.clickTool("arrow");
+ mouse.down(5, 0);
+ mouse.up(300, 50);
+
+ elementStats = stats?.querySelector("#elementStats");
+ });
+
+ beforeAll(() => {
+ mockBoundingClientRect();
+ });
+
+ afterAll(() => {
+ restoreOriginalGetBoundingClientRect();
+ });
+
+ it("should remain bound to linear element on small position change", async () => {
+ const linear = h.elements[1] as ExcalidrawLinearElement;
+ const inputX = UI.queryStatsProperty("X")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+
+ expect(linear.startBinding).not.toBe(null);
+ expect(inputX).not.toBeNull();
+ UI.updateInput(inputX, String("204"));
+ expect(linear.startBinding).not.toBe(null);
+ });
+
+ it("should remain bound to linear element on small angle change", async () => {
+ const linear = h.elements[1] as ExcalidrawLinearElement;
+ const inputAngle = UI.queryStatsProperty("A")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+
+ expect(linear.startBinding).not.toBe(null);
+ UI.updateInput(inputAngle, String("1"));
+ expect(linear.startBinding).not.toBe(null);
+ });
+
+ it("should unbind linear element on large position change", async () => {
+ const linear = h.elements[1] as ExcalidrawLinearElement;
+ const inputX = UI.queryStatsProperty("X")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+
+ expect(linear.startBinding).not.toBe(null);
+ expect(inputX).not.toBeNull();
+ UI.updateInput(inputX, String("254"));
+ expect(linear.startBinding).toBe(null);
+ });
+
+ it("should remain bound to linear element on small angle change", async () => {
+ const linear = h.elements[1] as ExcalidrawLinearElement;
+ const inputAngle = UI.queryStatsProperty("A")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+
+ expect(linear.startBinding).not.toBe(null);
+ UI.updateInput(inputAngle, String("45"));
+ expect(linear.startBinding).toBe(null);
+ });
+});
+
+// single element
+describe("stats for a generic element", () => {
+ beforeEach(async () => {
+ localStorage.clear();
+ renderStaticScene.mockClear();
+ reseed(7);
+ setDateTimeForTests("201933152653");
+
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+ API.setElements([]);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 1,
+ clientY: 1,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+ stats = UI.queryStats();
+
+ UI.clickTool("rectangle");
+ mouse.down();
+ mouse.up(200, 100);
+ elementStats = stats?.querySelector("#elementStats");
+ });
+
+ beforeAll(() => {
+ mockBoundingClientRect();
+ });
+
+ afterAll(() => {
+ restoreOriginalGetBoundingClientRect();
+ });
+
+ it("should open stats", () => {
+ expect(stats).toBeDefined();
+ expect(elementStats).toBeDefined();
+
+ // title
+ const title = elementStats?.querySelector("h3");
+ expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
+
+ // element type
+ const elementType = queryByTestId(elementStats!, "stats-element-type");
+ expect(elementType).toBeDefined();
+ expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
+
+ // properties
+ ["X", "Y", "W", "H", "A"].forEach((label) => () => {
+ expect(
+ stats!.querySelector?.(`.drag-input-container[data-testid="${label}"]`),
+ ).toBeDefined();
+ });
+ });
+
+ it("should be able to edit all properties for a general element", () => {
+ const rectangle = h.elements[0];
+ const initialX = rectangle.x;
+ const initialY = rectangle.y;
+
+ testInputProperty(rectangle, "width", "W", 200, 100);
+ testInputProperty(rectangle, "height", "H", 100, 200);
+ testInputProperty(rectangle, "x", "X", initialX, 230);
+ testInputProperty(rectangle, "y", "Y", initialY, 220);
+ testInputProperty(rectangle, "angle", "A", 0, 45);
+ });
+
+ it("should keep only two decimal places", () => {
+ const rectangle = h.elements[0];
+ const rectangleId = rectangle.id;
+
+ const input = UI.queryStatsProperty("W")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(input).toBeDefined();
+ expect(input.value).toBe(rectangle.width.toString());
+ UI.updateInput(input, "123.123");
+ expect(h.elements.length).toBe(1);
+ expect(rectangle.id).toBe(rectangleId);
+ expect(input.value).toBe("123.12");
+ expect(rectangle.width).toBe(123.12);
+
+ UI.updateInput(input, "88.98766");
+ expect(input.value).toBe("88.99");
+ expect(rectangle.width).toBe(88.99);
+ });
+
+ it("should update input x and y when angle is changed", () => {
+ const rectangle = h.elements[0];
+ const [cx, cy] = [
+ rectangle.x + rectangle.width / 2,
+ rectangle.y + rectangle.height / 2,
+ ];
+ const [topLeftX, topLeftY] = pointRotateRads(
+ pointFrom(rectangle.x, rectangle.y),
+ pointFrom(cx, cy),
+ rectangle.angle,
+ );
+
+ const xInput = UI.queryStatsProperty("X")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+
+ const yInput = UI.queryStatsProperty("Y")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+
+ expect(xInput.value).toBe(topLeftX.toString());
+ expect(yInput.value).toBe(topLeftY.toString());
+
+ testInputProperty(rectangle, "angle", "A", 0, 45);
+
+ let [newTopLeftX, newTopLeftY] = pointRotateRads(
+ pointFrom(rectangle.x, rectangle.y),
+ pointFrom(cx, cy),
+ rectangle.angle,
+ );
+
+ expect(newTopLeftX.toString()).not.toEqual(xInput.value);
+ expect(newTopLeftY.toString()).not.toEqual(yInput.value);
+
+ testInputProperty(rectangle, "angle", "A", 45, 66);
+
+ [newTopLeftX, newTopLeftY] = pointRotateRads(
+ pointFrom(rectangle.x, rectangle.y),
+ pointFrom(cx, cy),
+ rectangle.angle,
+ );
+ expect(newTopLeftX.toString()).not.toEqual(xInput.value);
+ expect(newTopLeftY.toString()).not.toEqual(yInput.value);
+ });
+
+ it("should fix top left corner when width or height is changed", () => {
+ const rectangle = h.elements[0];
+
+ testInputProperty(rectangle, "angle", "A", 0, 45);
+ let [cx, cy] = [
+ rectangle.x + rectangle.width / 2,
+ rectangle.y + rectangle.height / 2,
+ ];
+ const [topLeftX, topLeftY] = pointRotateRads(
+ pointFrom(rectangle.x, rectangle.y),
+ pointFrom(cx, cy),
+ rectangle.angle,
+ );
+ testInputProperty(rectangle, "width", "W", rectangle.width, 400);
+ [cx, cy] = [
+ rectangle.x + rectangle.width / 2,
+ rectangle.y + rectangle.height / 2,
+ ];
+ let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
+ pointFrom(rectangle.x, rectangle.y),
+ pointFrom(cx, cy),
+ rectangle.angle,
+ );
+ expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
+ expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
+
+ testInputProperty(rectangle, "height", "H", rectangle.height, 400);
+ [cx, cy] = [
+ rectangle.x + rectangle.width / 2,
+ rectangle.y + rectangle.height / 2,
+ ];
+ [currentTopLeftX, currentTopLeftY] = pointRotateRads(
+ pointFrom(rectangle.x, rectangle.y),
+ pointFrom(cx, cy),
+ rectangle.angle,
+ );
+
+ expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
+ expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
+ });
+});
+
+describe("stats for a non-generic element", () => {
+ beforeEach(async () => {
+ localStorage.clear();
+ renderStaticScene.mockClear();
+ reseed(7);
+ setDateTimeForTests("201933152653");
+
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+ API.setElements([]);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 1,
+ clientY: 1,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+ stats = UI.queryStats();
+ });
+
+ beforeAll(() => {
+ mockBoundingClientRect();
+ });
+
+ afterAll(() => {
+ restoreOriginalGetBoundingClientRect();
+ });
+
+ it("text element", async () => {
+ UI.clickTool("text");
+ mouse.clickAt(20, 30);
+ const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello!");
+ act(() => {
+ editor.blur();
+ });
+
+ const text = h.elements[0] as ExcalidrawTextElement;
+ mouse.clickOn(text);
+
+ elementStats = stats?.querySelector("#elementStats");
+
+ // can change font size
+ const input = UI.queryStatsProperty("F")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(input).toBeDefined();
+ expect(input.value).toBe(text.fontSize.toString());
+ UI.updateInput(input, "36");
+ expect(text.fontSize).toBe(36);
+
+ // cannot change width or height
+ const width = UI.queryStatsProperty("W")?.querySelector(".drag-input");
+ expect(width).toBeUndefined();
+ const height = UI.queryStatsProperty("H")?.querySelector(".drag-input");
+ expect(height).toBeUndefined();
+
+ // min font size is 4
+ UI.updateInput(input, "0");
+ expect(text.fontSize).not.toBe(0);
+ expect(text.fontSize).toBe(4);
+ });
+
+ it("frame element", () => {
+ const frame = API.createElement({
+ id: "id0",
+ type: "frame",
+ x: 150,
+ width: 150,
+ });
+ API.setElements([frame]);
+ API.setAppState({
+ selectedElementIds: {
+ [frame.id]: true,
+ },
+ });
+
+ elementStats = stats?.querySelector("#elementStats");
+
+ expect(elementStats).toBeDefined();
+
+ // cannot change angle
+ const angle = UI.queryStatsProperty("A")?.querySelector(".drag-input");
+ expect(angle).toBeUndefined();
+
+ // can change width or height
+ testInputProperty(frame, "width", "W", frame.width, 250);
+ testInputProperty(frame, "height", "H", frame.height, 500);
+ });
+
+ it("image element", () => {
+ const image = API.createElement({ type: "image", width: 200, height: 100 });
+ API.setElements([image]);
+ mouse.clickOn(image);
+ API.setAppState({
+ selectedElementIds: {
+ [image.id]: true,
+ },
+ });
+ elementStats = stats?.querySelector("#elementStats");
+ expect(elementStats).toBeDefined();
+ const widthToHeight = image.width / image.height;
+
+ // when width or height is changed, the aspect ratio is preserved
+ testInputProperty(image, "width", "W", image.width, 400);
+ expect(image.width).toBe(400);
+ expect(image.width / image.height).toBe(widthToHeight);
+
+ testInputProperty(image, "height", "H", image.height, 80);
+ expect(image.height).toBe(80);
+ expect(image.width / image.height).toBe(widthToHeight);
+ });
+
+ it("should display fontSize for bound text", () => {
+ const container = API.createElement({
+ type: "rectangle",
+ width: 200,
+ height: 100,
+ });
+ const text = API.createElement({
+ type: "text",
+ width: 200,
+ height: 100,
+ containerId: container.id,
+ fontSize: 20,
+ });
+ mutateElement(container, {
+ boundElements: [{ type: "text", id: text.id }],
+ });
+ API.setElements([container, text]);
+
+ API.setSelectedElements([container]);
+ const fontSize = UI.queryStatsProperty("F")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(fontSize).toBeDefined();
+
+ UI.updateInput(fontSize, "40");
+
+ expect(text.fontSize).toBe(40);
+ });
+});
+
+// multiple elements
+describe("stats for multiple elements", () => {
+ beforeEach(async () => {
+ mouse.reset();
+ localStorage.clear();
+ renderStaticScene.mockClear();
+ reseed(7);
+ setDateTimeForTests("201933152653");
+
+ await render(<Excalidraw handleKeyboardGlobally={true} />);
+
+ API.setElements([]);
+
+ fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
+ button: 2,
+ clientX: 1,
+ clientY: 1,
+ });
+ const contextMenu = UI.queryContextMenu();
+ fireEvent.click(queryByTestId(contextMenu!, "stats")!);
+ stats = UI.queryStats();
+ });
+
+ beforeAll(() => {
+ mockBoundingClientRect();
+ });
+
+ afterAll(() => {
+ restoreOriginalGetBoundingClientRect();
+ });
+
+ it("should display MIXED for elements with different values", () => {
+ UI.clickTool("rectangle");
+ mouse.down();
+ mouse.up(200, 100);
+
+ UI.clickTool("ellipse");
+ mouse.down(50, 50);
+ mouse.up(100, 100);
+
+ UI.clickTool("diamond");
+ mouse.down(-100, -100);
+ mouse.up(125, 145);
+
+ API.setAppState({
+ selectedElementIds: h.elements.reduce((acc, el) => {
+ acc[el.id] = true;
+ return acc;
+ }, {} as Record<string, true>),
+ });
+
+ elementStats = stats?.querySelector("#elementStats");
+
+ const width = UI.queryStatsProperty("W")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(width?.value).toBe("Mixed");
+ const height = UI.queryStatsProperty("H")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(height?.value).toBe("Mixed");
+ const angle = UI.queryStatsProperty("A")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(angle.value).toBe("0");
+
+ UI.updateInput(width, "250");
+ h.elements.forEach((el) => {
+ expect(el.width).toBe(250);
+ });
+
+ UI.updateInput(height, "450");
+ h.elements.forEach((el) => {
+ expect(el.height).toBe(450);
+ });
+ });
+
+ it("should display a property when one of the elements is editable for that property", async () => {
+ // text, rectangle, frame
+ UI.clickTool("text");
+ mouse.clickAt(20, 30);
+ const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
+ const editor = await getTextEditor(textEditorSelector, true);
+ updateTextEditor(editor, "Hello!");
+ act(() => {
+ editor.blur();
+ });
+
+ UI.clickTool("rectangle");
+ mouse.down();
+ mouse.up(200, 100);
+
+ const frame = API.createElement({
+ type: "frame",
+ x: 150,
+ width: 150,
+ });
+
+ API.setElements([...h.elements, frame]);
+
+ const text = h.elements.find((el) => el.type === "text");
+ const rectangle = h.elements.find((el) => el.type === "rectangle");
+
+ API.setAppState({
+ selectedElementIds: h.elements.reduce((acc, el) => {
+ acc[el.id] = true;
+ return acc;
+ }, {} as Record<string, true>),
+ });
+
+ elementStats = stats?.querySelector("#elementStats");
+
+ const width = UI.queryStatsProperty("W")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(width).toBeDefined();
+ expect(width.value).toBe("Mixed");
+
+ const height = UI.queryStatsProperty("H")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(height).toBeDefined();
+ expect(height.value).toBe("Mixed");
+
+ const angle = UI.queryStatsProperty("A")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(angle).toBeDefined();
+ expect(angle.value).toBe("0");
+
+ const fontSize = UI.queryStatsProperty("F")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(fontSize).toBeDefined();
+
+ // changing width does not affect text
+ UI.updateInput(width, "200");
+
+ expect(rectangle?.width).toBe(200);
+ expect(frame.width).toBe(200);
+ expect(text?.width).not.toBe(200);
+
+ UI.updateInput(angle, "40");
+
+ const angleInRadian = degreesToRadians(40 as Degrees);
+ expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
+ expect(text?.angle).toBeCloseTo(angleInRadian, 4);
+ expect(frame.angle).toBe(0);
+ });
+
+ it("should treat groups as single units", () => {
+ const createAndSelectGroup = () => {
+ UI.clickTool("rectangle");
+ mouse.down();
+ mouse.up(100, 100);
+
+ UI.clickTool("rectangle");
+ mouse.down(0, 0);
+ mouse.up(100, 100);
+
+ mouse.reset();
+ Keyboard.withModifierKeys({ shift: true }, () => {
+ mouse.click();
+ });
+
+ API.executeAction(actionGroup);
+ };
+
+ createAndSelectGroup();
+
+ const elementsInGroup = h.elements.filter((el) => isInGroup(el));
+ let [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+
+ elementStats = stats?.querySelector("#elementStats");
+
+ const x = UI.queryStatsProperty("X")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+
+ expect(x).toBeDefined();
+ expect(Number(x.value)).toBe(x1);
+
+ UI.updateInput(x, "300");
+
+ expect(h.elements[0].x).toBe(300);
+ expect(h.elements[1].x).toBe(400);
+ expect(x.value).toBe("300");
+
+ const y = UI.queryStatsProperty("Y")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+
+ expect(y).toBeDefined();
+ expect(Number(y.value)).toBe(y1);
+
+ UI.updateInput(y, "200");
+
+ expect(h.elements[0].y).toBe(200);
+ expect(h.elements[1].y).toBe(300);
+ expect(y.value).toBe("200");
+
+ const width = UI.queryStatsProperty("W")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(width).toBeDefined();
+ expect(Number(width.value)).toBe(200);
+
+ const height = UI.queryStatsProperty("H")?.querySelector(
+ ".drag-input",
+ ) as HTMLInputElement;
+ expect(height).toBeDefined();
+ expect(Number(height.value)).toBe(200);
+
+ UI.updateInput(width, "400");
+
+ [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+ let newGroupWidth = x2 - x1;
+
+ expect(newGroupWidth).toBeCloseTo(400, 4);
+
+ UI.updateInput(width, "300");
+
+ [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+ newGroupWidth = x2 - x1;
+ expect(newGroupWidth).toBeCloseTo(300, 4);
+
+ UI.updateInput(height, "500");
+
+ [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
+ const newGroupHeight = y2 - y1;
+ expect(newGroupHeight).toBeCloseTo(500, 4);
+ });
+});
diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts
new file mode 100644
index 0000000..61555ba
--- /dev/null
+++ b/packages/excalidraw/components/Stats/utils.ts
@@ -0,0 +1,219 @@
+import type { Radians } from "@excalidraw/math";
+import { pointFrom, pointRotateRads } from "@excalidraw/math";
+import {
+ bindOrUnbindLinearElements,
+ updateBoundElements,
+} from "../../element/binding";
+import { mutateElement } from "../../element/mutateElement";
+import { getBoundTextElement } from "../../element/textElement";
+import {
+ isFrameLikeElement,
+ isLinearElement,
+ isTextElement,
+} from "../../element/typeChecks";
+import type {
+ ElementsMap,
+ ExcalidrawElement,
+ NonDeletedExcalidrawElement,
+ NonDeletedSceneElementsMap,
+} from "../../element/types";
+import {
+ getSelectedGroupIds,
+ getElementsInGroup,
+ isInGroup,
+} from "../../groups";
+import type Scene from "../../scene/Scene";
+import type { AppState } from "../../types";
+
+export type StatsInputProperty =
+ | "x"
+ | "y"
+ | "width"
+ | "height"
+ | "angle"
+ | "fontSize"
+ | "gridStep";
+
+export const SMALLEST_DELTA = 0.01;
+
+export const isPropertyEditable = (
+ element: ExcalidrawElement,
+ property: keyof ExcalidrawElement,
+) => {
+ if (property === "height" && isTextElement(element)) {
+ return false;
+ }
+ if (property === "width" && isTextElement(element)) {
+ return false;
+ }
+ if (property === "angle" && isFrameLikeElement(element)) {
+ return false;
+ }
+ return true;
+};
+
+export const getStepSizedValue = (value: number, stepSize: number) => {
+ const v = value + stepSize / 2;
+ return v - (v % stepSize);
+};
+
+export type AtomicUnit = Record<string, true>;
+export const getElementsInAtomicUnit = (
+ atomicUnit: AtomicUnit,
+ elementsMap: ElementsMap,
+ originalElementsMap?: ElementsMap,
+) => {
+ return Object.keys(atomicUnit)
+ .map((id) => ({
+ original: (originalElementsMap ?? elementsMap).get(id),
+ latest: elementsMap.get(id),
+ }))
+ .filter((el) => el.original !== undefined && el.latest !== undefined) as {
+ original: NonDeletedExcalidrawElement;
+ latest: NonDeletedExcalidrawElement;
+ }[];
+};
+
+export const newOrigin = (
+ x1: number,
+ y1: number,
+ w1: number,
+ h1: number,
+ w2: number,
+ h2: number,
+ angle: number,
+) => {
+ /**
+ * The formula below is the result of solving
+ * rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
+ * where rotate is the function defined in math.ts
+ *
+ * This is so that the new origin (x2, y2),
+ * when rotated against the new center (cx2, cy2),
+ * coincides with (x1, y1) rotated against (cx1, cy1)
+ *
+ * The reason for doing this computation is so the element's top left corner
+ * on the canvas remains fixed after any changes in its dimension.
+ */
+
+ return {
+ x:
+ x1 +
+ (w1 - w2) / 2 +
+ ((w2 - w1) / 2) * Math.cos(angle) +
+ ((h1 - h2) / 2) * Math.sin(angle),
+ y:
+ y1 +
+ (h1 - h2) / 2 +
+ ((w2 - w1) / 2) * Math.sin(angle) +
+ ((h2 - h1) / 2) * Math.cos(angle),
+ };
+};
+
+export const moveElement = (
+ newTopLeftX: number,
+ newTopLeftY: number,
+ originalElement: ExcalidrawElement,
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+ scene: Scene,
+ originalElementsMap: ElementsMap,
+ shouldInformMutation = true,
+) => {
+ const latestElement = elementsMap.get(originalElement.id);
+ if (!latestElement) {
+ return;
+ }
+ const [cx, cy] = [
+ originalElement.x + originalElement.width / 2,
+ originalElement.y + originalElement.height / 2,
+ ];
+ const [topLeftX, topLeftY] = pointRotateRads(
+ pointFrom(originalElement.x, originalElement.y),
+ pointFrom(cx, cy),
+ originalElement.angle,
+ );
+
+ const changeInX = newTopLeftX - topLeftX;
+ const changeInY = newTopLeftY - topLeftY;
+
+ const [x, y] = pointRotateRads(
+ pointFrom(newTopLeftX, newTopLeftY),
+ pointFrom(cx + changeInX, cy + changeInY),
+ -originalElement.angle as Radians,
+ );
+
+ mutateElement(
+ latestElement,
+ {
+ x,
+ y,
+ },
+ shouldInformMutation,
+ );
+ updateBindings(latestElement, elementsMap, elements, scene);
+
+ const boundTextElement = getBoundTextElement(
+ originalElement,
+ originalElementsMap,
+ );
+ if (boundTextElement) {
+ const latestBoundTextElement = elementsMap.get(boundTextElement.id);
+ latestBoundTextElement &&
+ mutateElement(
+ latestBoundTextElement,
+ {
+ x: boundTextElement.x + changeInX,
+ y: boundTextElement.y + changeInY,
+ },
+ shouldInformMutation,
+ );
+ }
+};
+
+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;
+};
+
+export const updateBindings = (
+ latestElement: ExcalidrawElement,
+ elementsMap: NonDeletedSceneElementsMap,
+ elements: readonly NonDeletedExcalidrawElement[],
+ scene: Scene,
+ options?: {
+ simultaneouslyUpdated?: readonly ExcalidrawElement[];
+ newSize?: { width: number; height: number };
+ zoom?: AppState["zoom"];
+ },
+) => {
+ if (isLinearElement(latestElement)) {
+ bindOrUnbindLinearElements(
+ [latestElement],
+ elementsMap,
+ elements,
+ scene,
+ true,
+ [],
+ options?.zoom,
+ );
+ } else {
+ updateBoundElements(latestElement, elementsMap, options);
+ }
+};