diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index b398a56467..59368d4843 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -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 = ({ shouldChangeByStepSize, nextValue, scene, + setAppState, + originalAppState, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -47,13 +65,24 @@ const handleDegreeChange: DragInputCallbackType = ({ 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 = ({ 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} diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index b4795308d8..8cb07cc6b6 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -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["setState"]; +}) => void; + +export type DragFinishedCallbackType = (props: { + originalElements: readonly E[]; + originalElementsMap: ElementsMap; + scene: Scene; + originalAppState: AppState; + accumulatedChange: number; + setAppState: React.Component["setState"]; }) => void; interface StatsDragInputProps< @@ -47,6 +57,7 @@ interface StatsDragInputProps< editable?: boolean; shouldKeepAspectRatio?: boolean; dragInputCallback: DragInputCallbackType; + dragFinishedCallback?: DragFinishedCallbackType; 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) => { const app = useApp(); + const setAppState = useExcalidrawSetAppState(); const inputRef = useRef(null); const labelRef = useRef(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, }); diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index 98058efecd..243260925b 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -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(); diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index bf6dfd1613..2c62fa1d4f 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -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, ); }; diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 85e85a59fe..528e240365 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -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 () => { diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index e4cab04130..9d005b9797 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -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); + // } } };