Stats: Angle setting now works properly and resets on unconnected bindings

This commit is contained in:
Mark Tolmacs 2025-03-29 21:19:56 +01:00
parent 4efa6f69e5
commit 88d4c4fe8d
6 changed files with 155 additions and 61 deletions

View file

@ -3,20 +3,36 @@ import { degreesToRadians, radiansToDegrees } from "@excalidraw/math";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { getBoundTextElement } from "@excalidraw/element/textElement";
import { isArrowElement, isElbowArrow } from "@excalidraw/element/typeChecks";
import {
isArrowElement,
isBindableElement,
isElbowArrow,
} from "@excalidraw/element/typeChecks";
import type { Degrees } from "@excalidraw/math";
import {
getSuggestedBindingsForArrows,
updateBoundElements,
} from "@excalidraw/element/binding";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import type { AppState } from "@excalidraw/excalidraw/types";
import type { Degrees, Radians } from "@excalidraw/math";
import type {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "@excalidraw/element/types";
import { angleIcon } from "../icons";
import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
import type { DragInputCallbackType } from "./DragInput";
import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
interface AngleProps {
element: ExcalidrawElement;
@ -33,6 +49,8 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
shouldChangeByStepSize,
nextValue,
scene,
setAppState,
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
@ -47,13 +65,24 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap);
if (isBindableElement(latestElement)) {
updateBoundElements(latestElement, elementsMap);
}
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
setAppState({
suggestedBindings: getSuggestedBindingsForArrows(
[latestElement] as NonDeletedExcalidrawElement[],
elementsMap,
originalAppState.zoom,
),
});
return;
}
@ -73,12 +102,62 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
mutateElement(latestElement, {
angle: nextAngle,
});
updateBindings(latestElement, elementsMap);
if (isBindableElement(latestElement)) {
updateBoundElements(latestElement, elementsMap);
}
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: nextAngle });
}
setAppState({
suggestedBindings: getSuggestedBindingsForArrows(
[latestElement] as NonDeletedExcalidrawElement[],
elementsMap,
originalAppState.zoom,
),
});
}
};
const handleFinished: DragFinishedCallbackType = ({
originalElements,
originalAppState,
scene,
accumulatedChange,
setAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
if (origElement) {
const latestElement = elementsMap.get(origElement.id);
if (latestElement) {
updateBindings(latestElement, elementsMap, originalAppState.zoom, () => {
const revertAngle = (latestElement.angle -
degreesToRadians(accumulatedChange as Degrees)) as Radians;
mutateElement(latestElement, {
angle: revertAngle,
});
const boundTextElement = getBoundTextElement(
latestElement,
elementsMap,
);
if (boundTextElement && !isArrowElement(latestElement)) {
mutateElement(boundTextElement, { angle: revertAngle });
}
setAppState({
suggestedBindings: [],
});
});
}
}
};
@ -90,6 +169,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
elements={[element]}
dragInputCallback={handleDegreeChange}
dragFinishedCallback={handleFinished}
editable={isPropertyEditable(element, "angle")}
scene={scene}
appState={appState}

View file

@ -8,7 +8,7 @@ import { deepCopyElement } from "@excalidraw/element/duplicate";
import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import { CaptureUpdateAction } from "../../store";
import { useApp } from "../App";
import { useApp, useExcalidrawSetAppState } from "../App";
import { InlineIcon } from "../InlineIcon";
import { SMALLEST_DELTA } from "./utils";
@ -34,6 +34,16 @@ export type DragInputCallbackType<
property: P;
originalAppState: AppState;
setInputValue: (value: number) => void;
setAppState: React.Component<any, AppState>["setState"];
}) => void;
export type DragFinishedCallbackType<E = ExcalidrawElement> = (props: {
originalElements: readonly E[];
originalElementsMap: ElementsMap;
scene: Scene;
originalAppState: AppState;
accumulatedChange: number;
setAppState: React.Component<any, AppState>["setState"];
}) => void;
interface StatsDragInputProps<
@ -47,6 +57,7 @@ interface StatsDragInputProps<
editable?: boolean;
shouldKeepAspectRatio?: boolean;
dragInputCallback: DragInputCallbackType<T, E>;
dragFinishedCallback?: DragFinishedCallbackType<E>;
property: T;
scene: Scene;
appState: AppState;
@ -61,6 +72,7 @@ const StatsDragInput = <
label,
icon,
dragInputCallback,
dragFinishedCallback,
value,
elements,
editable = true,
@ -71,6 +83,7 @@ const StatsDragInput = <
sensitivity = 1,
}: StatsDragInputProps<T, E>) => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
@ -135,6 +148,7 @@ const StatsDragInput = <
property,
originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)),
setAppState,
});
app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -262,6 +276,7 @@ const StatsDragInput = <
scene,
originalAppState,
setInputValue: (value) => setInputValue(String(value)),
setAppState,
});
stepChange = 0;
@ -282,6 +297,17 @@ const StatsDragInput = <
false,
);
if (originalElements !== null && originalElementsMap !== null) {
dragFinishedCallback?.({
originalElements,
originalElementsMap,
scene,
originalAppState,
accumulatedChange,
setAppState,
});
}
app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});

View file

@ -8,7 +8,6 @@ import { getCommonBounds } from "@excalidraw/element/bounds";
import type {
ElementsMap,
ExcalidrawElement,
NonDeletedExcalidrawElement,
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
@ -40,7 +39,6 @@ const moveElements = (
originalElements: readonly ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
originalElementsMap: ElementsMap,
scene: Scene,
) => {
for (let i = 0; i < elements.length; i++) {
const origElement = originalElements[i];
@ -66,8 +64,6 @@ const moveElements = (
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@ -79,9 +75,7 @@ const moveGroupTo = (
nextY: number,
originalElements: ExcalidrawElement[],
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
originalElementsMap: ElementsMap,
scene: Scene,
) => {
const [x1, y1, ,] = getCommonBounds(originalElements);
const offsetX = nextX - x1;
@ -113,8 +107,6 @@ const moveGroupTo = (
topLeftY + offsetY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@ -135,7 +127,6 @@ const handlePositionChange: DragInputCallbackType<
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
if (nextValue !== undefined) {
for (const atomicUnit of getAtomicUnits(
@ -160,9 +151,7 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
elementsInUnit.map((el) => el.original),
elementsMap,
elements,
originalElementsMap,
scene,
);
} else {
const origElement = elementsInUnit[0]?.original;
@ -189,8 +178,6 @@ const handlePositionChange: DragInputCallbackType<
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
false,
);
@ -217,7 +204,6 @@ const handlePositionChange: DragInputCallbackType<
originalElements,
elementsMap,
originalElementsMap,
scene,
);
scene.triggerUpdate();

View file

@ -38,7 +38,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
originalAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const elements = scene.getNonDeletedElements();
const origElement = originalElements[0];
const [cx, cy] = [
origElement.x + origElement.width / 2,
@ -134,8 +133,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
return;
@ -167,8 +164,6 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
newTopLeftY,
origElement,
elementsMap,
elements,
scene,
originalElementsMap,
);
};

View file

@ -128,27 +128,19 @@ describe("binding with linear elements", () => {
restoreOriginalGetBoundingClientRect();
});
// UX RATIONALE: Since we force a fixed distance from elements angle changes
// would result in a "jump" the moment the bound object is moved
it(
"should not remain bound to linear element even" +
" on small position change",
async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputX = UI.queryStatsProperty("X")?.querySelector(
".drag-input",
) as HTMLInputElement;
it("should remain bound to linear element even 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).toBe(null);
},
);
expect(linear.startBinding).not.toBe(null);
expect(inputX).not.toBeNull();
UI.updateInput(inputX, String("204"));
expect(linear.startBinding).not.toBe(null);
});
// UX RATIONALE: Since we force a fixed distance from elements angle changes
// would result in a "jump" the moment the bound object is moved
it("should not remain bound to linear element on any angle change", async () => {
it("should remain bound to linear element on any angle change", async () => {
const linear = h.elements[1] as ExcalidrawLinearElement;
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
".drag-input",
@ -156,7 +148,7 @@ describe("binding with linear elements", () => {
expect(linear.startBinding).not.toBe(null);
UI.updateInput(inputAngle, String("1"));
expect(linear.startBinding).toBe(null);
expect(linear.startBinding).not.toBe(null);
});
it("should unbind linear element on large position change", async () => {

View file

@ -16,6 +16,7 @@ import {
} from "@excalidraw/element/groups";
import {
getOriginalBindingsIfStillCloseToArrowEnds,
unbindLinearElement,
updateBoundElements,
} from "@excalidraw/element/binding";
@ -29,7 +30,6 @@ import type {
NonDeletedSceneElementsMap,
} from "@excalidraw/element/types";
import type Scene from "../../scene/Scene";
import type { AppState } from "../../types";
export type StatsInputProperty =
@ -122,8 +122,6 @@ export const moveElement = (
newTopLeftY: number,
originalElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
elements: readonly NonDeletedExcalidrawElement[],
scene: Scene,
originalElementsMap: ElementsMap,
shouldInformMutation = true,
) => {
@ -159,7 +157,9 @@ export const moveElement = (
shouldInformMutation,
);
updateBindings(latestElement, elementsMap);
if (isBindableElement(latestElement)) {
updateBoundElements(latestElement, elementsMap);
}
const boundTextElement = getBoundTextElement(
originalElement,
@ -203,18 +203,33 @@ export const getAtomicUnits = (
export const updateBindings = (
latestElement: ExcalidrawElement,
elementsMap: NonDeletedSceneElementsMap,
options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[];
},
zoom?: AppState["zoom"],
remainedBound?: () => void,
) => {
if (isBindingElement(latestElement)) {
if (latestElement.startBinding) {
unbindLinearElement(latestElement, "start");
const [start, end] = getOriginalBindingsIfStillCloseToArrowEnds(
latestElement,
elementsMap,
zoom,
);
if (
(latestElement.startBinding && start) ||
(latestElement.endBinding && end)
) {
remainedBound?.();
} else {
if (latestElement.startBinding && !start) {
unbindLinearElement(latestElement, "start");
}
if (latestElement.endBinding && !end) {
unbindLinearElement(latestElement, "end");
}
}
if (latestElement.endBinding) {
unbindLinearElement(latestElement, "end");
}
} else if (isBindableElement(latestElement)) {
updateBoundElements(latestElement, elementsMap, options);
// else if (end) {
// updateBoundElements(end, elementsMap);
// }
}
};