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

pull/8187/head
David Luzar 7 months ago committed by GitHub
parent 6ba9bd60e8
commit 744b3e5d09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save