diff --git a/packages/excalidraw/components/Stats/Dimension.tsx b/packages/excalidraw/components/Stats/Dimension.tsx index a8868721bf..cfc0f2dda1 100644 --- a/packages/excalidraw/components/Stats/Dimension.tsx +++ b/packages/excalidraw/components/Stats/Dimension.tsx @@ -7,6 +7,9 @@ import { } from "@excalidraw/element"; import { resizeSingleElement } from "@excalidraw/element"; import { isImageElement } from "@excalidraw/element"; +import { isFrameLikeElement } from "@excalidraw/element"; +import { getElementsInResizingFrame } from "@excalidraw/element"; +import { replaceAllElementsInFrame } from "@excalidraw/element"; import type { ExcalidrawElement } from "@excalidraw/element/types"; @@ -15,7 +18,10 @@ import type { Scene } from "@excalidraw/element"; import DragInput from "./DragInput"; import { getStepSizedValue, isPropertyEditable } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; +import type { + DragFinishedCallbackType, + DragInputCallbackType, +} from "./DragInput"; import type { AppState } from "../../types"; interface DimensionDragInputProps { @@ -43,6 +49,8 @@ const handleDimensionChange: DragInputCallbackType< originalAppState, instantChange, scene, + app, + setAppState, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -153,6 +161,7 @@ const handleDimensionChange: DragInputCallbackType< return; } + // User types in a value to stats then presses Enter if (nextValue !== undefined) { const nextWidth = Math.max( property === "width" @@ -184,52 +193,123 @@ const handleDimensionChange: DragInputCallbackType< }, ); + // Handle frame membership update for resized frames + if (isFrameLikeElement(latestElement)) { + const nextElementsInFrame = getElementsInResizingFrame( + scene.getElementsIncludingDeleted(), + latestElement, + originalAppState, + scene.getNonDeletedElementsMap(), + ); + + const updatedElements = replaceAllElementsInFrame( + scene.getElementsIncludingDeleted(), + nextElementsInFrame, + latestElement, + app, + ); + + scene.replaceAllElements(updatedElements); + } + 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); - } - } + // Stats slider is dragged + { + const changeInWidth = property === "width" ? accumulatedChange : 0; + const changeInHeight = property === "height" ? accumulatedChange : 0; - 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) { + let nextWidth = Math.max(0, origElement.width + changeInWidth); if (property === "width") { - nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100; - } else { - nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100; + 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, + originalElementsMap, + scene, + property === "width" ? "e" : "s", + { + shouldMaintainAspectRatio: keepAspectRatio, + }, + ); + + // Handle highlighting frame element candidates + if (isFrameLikeElement(latestElement)) { + const nextElementsInFrame = getElementsInResizingFrame( + scene.getElementsIncludingDeleted(), + latestElement, + originalAppState, + scene.getNonDeletedElementsMap(), + ); + + setAppState({ + elementsToHighlight: nextElementsInFrame, + }); } } + } +}; - nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight); - nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); +const handleDragFinished: DragFinishedCallbackType = ({ + setAppState, + app, + originalElements, + originalAppState, +}) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); + const origElement = originalElements?.[0]; + const latestElement = origElement && elementsMap.get(origElement.id); - resizeSingleElement( - nextWidth, - nextHeight, + // Handle frame membership update for resized frames + if (latestElement && isFrameLikeElement(latestElement)) { + const nextElementsInFrame = getElementsInResizingFrame( + app.scene.getElementsIncludingDeleted(), latestElement, - origElement, - originalElementsMap, - scene, - property === "width" ? "e" : "s", - { - shouldMaintainAspectRatio: keepAspectRatio, - }, + originalAppState, + app.scene.getNonDeletedElementsMap(), ); + + const updatedElements = replaceAllElementsInFrame( + app.scene.getElementsIncludingDeleted(), + nextElementsInFrame, + latestElement, + app, + ); + + app.scene.replaceAllElements(updatedElements); + + setAppState({ + elementsToHighlight: null, + }); } }; @@ -269,6 +349,7 @@ const DimensionDragInput = ({ scene={scene} appState={appState} property={property} + dragFinishedCallback={handleDragFinished} /> ); }; diff --git a/packages/excalidraw/components/Stats/DragInput.tsx b/packages/excalidraw/components/Stats/DragInput.tsx index 56138d8103..259cb47180 100644 --- a/packages/excalidraw/components/Stats/DragInput.tsx +++ b/packages/excalidraw/components/Stats/DragInput.tsx @@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types"; import type { Scene } from "@excalidraw/element"; -import { useApp } from "../App"; +import { useApp, useExcalidrawSetAppState } from "../App"; import { InlineIcon } from "../InlineIcon"; import { SMALLEST_DELTA } from "./utils"; @@ -36,6 +36,15 @@ export type DragInputCallbackType< property: P; originalAppState: AppState; setInputValue: (value: number) => void; + app: ReturnType; + setAppState: ReturnType; +}) => void; + +export type DragFinishedCallbackType = (props: { + app: ReturnType; + setAppState: ReturnType; + originalElements: readonly E[] | null; + originalAppState: AppState; }) => void; interface StatsDragInputProps< @@ -54,6 +63,7 @@ interface StatsDragInputProps< appState: AppState; /** how many px you need to drag to get 1 unit change */ sensitivity?: number; + dragFinishedCallback?: DragFinishedCallbackType; } const StatsDragInput = < @@ -71,8 +81,10 @@ const StatsDragInput = < scene, appState, sensitivity = 1, + dragFinishedCallback, }: StatsDragInputProps) => { const app = useApp(); + const setAppState = useExcalidrawSetAppState(); const inputRef = useRef(null); const labelRef = useRef(null); @@ -137,6 +149,8 @@ const StatsDragInput = < property, originalAppState: appState, setInputValue: (value) => setInputValue(String(value)), + app, + setAppState, }); app.syncActionResult({ captureUpdate: CaptureUpdateAction.IMMEDIATELY, @@ -263,6 +277,8 @@ const StatsDragInput = < scene, originalAppState, setInputValue: (value) => setInputValue(String(value)), + app, + setAppState, }); stepChange = 0; @@ -287,6 +303,14 @@ const StatsDragInput = < captureUpdate: CaptureUpdateAction.IMMEDIATELY, }); + // Notify implementors + dragFinishedCallback?.({ + app, + setAppState, + originalElements, + originalAppState, + }); + lastPointer = null; accumulatedChange = 0; stepChange = 0; diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 65f59ffe31..539a2ad59e 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -2,7 +2,12 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math"; import { useMemo } from "react"; import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common"; -import { updateBoundElements } from "@excalidraw/element"; +import { + getElementsInResizingFrame, + isFrameLikeElement, + replaceAllElementsInFrame, + updateBoundElements, +} from "@excalidraw/element"; import { rescalePointsInElement, resizeSingleElement, @@ -25,7 +30,10 @@ import DragInput from "./DragInput"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getElementsInAtomicUnit } from "./utils"; -import type { DragInputCallbackType } from "./DragInput"; +import type { + DragFinishedCallbackType, + DragInputCallbackType, +} from "./DragInput"; import type { AtomicUnit } from "./utils"; import type { AppState } from "../../types"; @@ -153,6 +161,8 @@ const handleDimensionChange: DragInputCallbackType< nextValue, scene, property, + setAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const atomicUnits = getAtomicUnits(originalElements, originalAppState); @@ -239,6 +249,25 @@ const handleDimensionChange: DragInputCallbackType< shouldInformMutation: false, }, ); + + // Handle frame membership update for resized frames + if (isFrameLikeElement(latestElement)) { + const nextElementsInFrame = getElementsInResizingFrame( + scene.getElementsIncludingDeleted(), + latestElement, + originalAppState, + scene.getNonDeletedElementsMap(), + ); + + const updatedElements = replaceAllElementsInFrame( + scene.getElementsIncludingDeleted(), + nextElementsInFrame, + latestElement, + app, + ); + + scene.replaceAllElements(updatedElements); + } } } } @@ -250,6 +279,7 @@ const handleDimensionChange: DragInputCallbackType< const changeInWidth = property === "width" ? accumulatedChange : 0; const changeInHeight = property === "height" ? accumulatedChange : 0; + const elementsToHighlight: ExcalidrawElement[] = []; for (const atomicUnit of atomicUnits) { const elementsInUnit = getElementsInAtomicUnit( @@ -342,13 +372,63 @@ const handleDimensionChange: DragInputCallbackType< shouldInformMutation: false, }, ); + + // Handle highlighting frame element candidates + if (isFrameLikeElement(latestElement)) { + const nextElementsInFrame = getElementsInResizingFrame( + scene.getElementsIncludingDeleted(), + latestElement, + originalAppState, + scene.getNonDeletedElementsMap(), + ); + + elementsToHighlight.push(...nextElementsInFrame); + } } } } + setAppState({ + elementsToHighlight, + }); + scene.triggerUpdate(); }; +const handleDragFinished: DragFinishedCallbackType = ({ + setAppState, + app, + originalElements, + originalAppState, +}) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); + const origElement = originalElements?.[0]; + const latestElement = origElement && elementsMap.get(origElement.id); + + // Handle frame membership update for resized frames + if (latestElement && isFrameLikeElement(latestElement)) { + const nextElementsInFrame = getElementsInResizingFrame( + app.scene.getElementsIncludingDeleted(), + latestElement, + originalAppState, + app.scene.getNonDeletedElementsMap(), + ); + + const updatedElements = replaceAllElementsInFrame( + app.scene.getElementsIncludingDeleted(), + nextElementsInFrame, + latestElement, + app, + ); + + app.scene.replaceAllElements(updatedElements); + + setAppState({ + elementsToHighlight: null, + }); + } +}; + const MultiDimension = ({ property, elements, @@ -396,6 +476,7 @@ const MultiDimension = ({ appState={appState} property={property} scene={scene} + dragFinishedCallback={handleDragFinished} /> ); }; diff --git a/packages/excalidraw/components/Stats/stats.test.tsx b/packages/excalidraw/components/Stats/stats.test.tsx index 40cb598a0e..c52a721bff 100644 --- a/packages/excalidraw/components/Stats/stats.test.tsx +++ b/packages/excalidraw/components/Stats/stats.test.tsx @@ -737,3 +737,196 @@ describe("stats for multiple elements", () => { expect(newGroupHeight).toBeCloseTo(500, 4); }); }); + +describe("frame resizing behavior", () => { + beforeEach(async () => { + localStorage.clear(); + renderStaticScene.mockClear(); + reseed(7); + setDateTimeForTests("201933152653"); + + await render(); + + 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 add shapes to frame when resizing frame to encompass them", () => { + // Create a frame + const frame = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 100, + height: 100, + }); + + // Create a rectangle outside the frame + const rectangle = API.createElement({ + type: "rectangle", + x: 150, + y: 50, + width: 50, + height: 50, + }); + + API.setElements([frame, rectangle]); + + // Initially, rectangle should not be in the frame + expect(rectangle.frameId).toBe(null); + + // Select the frame + API.setAppState({ + selectedElementIds: { + [frame.id]: true, + }, + }); + + elementStats = stats?.querySelector("#elementStats"); + + // Find the width input and update it to encompass the rectangle + const widthInput = UI.queryStatsProperty("W")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + expect(widthInput).toBeDefined(); + expect(widthInput.value).toBe("100"); + + // Resize frame to width 250, which should encompass the rectangle + UI.updateInput(widthInput, "250"); + + // After resizing, the rectangle should now be part of the frame + expect(h.elements.find((el) => el.id === rectangle.id)?.frameId).toBe( + frame.id, + ); + }); + + it("should add multiple shapes when frame encompasses them through height resize", () => { + const frame = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 200, + height: 100, + }); + + const rectangle1 = API.createElement({ + type: "rectangle", + x: 50, + y: 150, + width: 50, + height: 50, + }); + + const rectangle2 = API.createElement({ + type: "rectangle", + x: 100, + y: 180, + width: 40, + height: 40, + }); + + API.setElements([frame, rectangle1, rectangle2]); + + // Initially, rectangles should not be in the frame + expect(rectangle1.frameId).toBe(null); + expect(rectangle2.frameId).toBe(null); + + // Select the frame + API.setAppState({ + selectedElementIds: { + [frame.id]: true, + }, + }); + + elementStats = stats?.querySelector("#elementStats"); + + // Resize frame height to encompass both rectangles + const heightInput = UI.queryStatsProperty("H")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + // Resize frame to height 250, which should encompass both rectangles + UI.updateInput(heightInput, "250"); + + // After resizing, both rectangles should now be part of the frame + expect(h.elements.find((el) => el.id === rectangle1.id)?.frameId).toBe( + frame.id, + ); + expect(h.elements.find((el) => el.id === rectangle2.id)?.frameId).toBe( + frame.id, + ); + }); + + it("should not affect shapes that remain outside frame after resize", () => { + const frame = API.createElement({ + type: "frame", + x: 0, + y: 0, + width: 100, + height: 100, + }); + + const insideRect = API.createElement({ + type: "rectangle", + x: 120, + y: 50, + width: 30, + height: 30, + }); + + const outsideRect = API.createElement({ + type: "rectangle", + x: 300, + y: 50, + width: 30, + height: 30, + }); + + API.setElements([frame, insideRect, outsideRect]); + + // Initially, both rectangles should not be in the frame + expect(insideRect.frameId).toBe(null); + expect(outsideRect.frameId).toBe(null); + + // Select the frame + API.setAppState({ + selectedElementIds: { + [frame.id]: true, + }, + }); + + elementStats = stats?.querySelector("#elementStats"); + + // Resize frame width to 200, which should only encompass insideRect + const widthInput = UI.queryStatsProperty("W")?.querySelector( + ".drag-input", + ) as HTMLInputElement; + + UI.updateInput(widthInput, "200"); + + // After resizing, only insideRect should be in the frame + expect(h.elements.find((el) => el.id === insideRect.id)?.frameId).toBe( + frame.id, + ); + expect(h.elements.find((el) => el.id === outsideRect.id)?.frameId).toBe( + null, + ); + }); +});