fix: Frame dimensions change by stats don't include new elements (#9568)

This commit is contained in:
Márk Tolmács 2025-06-16 14:07:03 +02:00 committed by GitHub
parent 0a19c93509
commit 8e27de2cdc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 418 additions and 39 deletions

View File

@ -7,6 +7,9 @@ import {
} from "@excalidraw/element"; } from "@excalidraw/element";
import { resizeSingleElement } from "@excalidraw/element"; import { resizeSingleElement } from "@excalidraw/element";
import { isImageElement } 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"; import type { ExcalidrawElement } from "@excalidraw/element/types";
@ -15,7 +18,10 @@ import type { Scene } from "@excalidraw/element";
import DragInput from "./DragInput"; import DragInput from "./DragInput";
import { getStepSizedValue, isPropertyEditable } from "./utils"; import { getStepSizedValue, isPropertyEditable } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
interface DimensionDragInputProps { interface DimensionDragInputProps {
@ -43,6 +49,8 @@ const handleDimensionChange: DragInputCallbackType<
originalAppState, originalAppState,
instantChange, instantChange,
scene, scene,
app,
setAppState,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0]; const origElement = originalElements[0];
@ -153,6 +161,7 @@ const handleDimensionChange: DragInputCallbackType<
return; return;
} }
// User types in a value to stats then presses Enter
if (nextValue !== undefined) { if (nextValue !== undefined) {
const nextWidth = Math.max( const nextWidth = Math.max(
property === "width" 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; return;
} }
const changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0;
let nextWidth = Math.max(0, origElement.width + changeInWidth); // Stats slider is dragged
if (property === "width") { {
if (shouldChangeByStepSize) { const changeInWidth = property === "width" ? accumulatedChange : 0;
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE); const changeInHeight = property === "height" ? accumulatedChange : 0;
} else {
nextWidth = Math.round(nextWidth);
}
}
let nextHeight = Math.max(0, origElement.height + changeInHeight); let nextWidth = Math.max(0, origElement.width + changeInWidth);
if (property === "height") {
if (shouldChangeByStepSize) {
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
} else {
nextHeight = Math.round(nextHeight);
}
}
if (keepAspectRatio) {
if (property === "width") { if (property === "width") {
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100; if (shouldChangeByStepSize) {
} else { nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100; } 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); const handleDragFinished: DragFinishedCallbackType = ({
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth); setAppState,
app,
originalElements,
originalAppState,
}) => {
const elementsMap = app.scene.getNonDeletedElementsMap();
const origElement = originalElements?.[0];
const latestElement = origElement && elementsMap.get(origElement.id);
resizeSingleElement( // Handle frame membership update for resized frames
nextWidth, if (latestElement && isFrameLikeElement(latestElement)) {
nextHeight, const nextElementsInFrame = getElementsInResizingFrame(
app.scene.getElementsIncludingDeleted(),
latestElement, latestElement,
origElement, originalAppState,
originalElementsMap, app.scene.getNonDeletedElementsMap(),
scene,
property === "width" ? "e" : "s",
{
shouldMaintainAspectRatio: keepAspectRatio,
},
); );
const updatedElements = replaceAllElementsInFrame(
app.scene.getElementsIncludingDeleted(),
nextElementsInFrame,
latestElement,
app,
);
app.scene.replaceAllElements(updatedElements);
setAppState({
elementsToHighlight: null,
});
} }
}; };
@ -269,6 +349,7 @@ const DimensionDragInput = ({
scene={scene} scene={scene}
appState={appState} appState={appState}
property={property} property={property}
dragFinishedCallback={handleDragFinished}
/> />
); );
}; };

View File

@ -11,7 +11,7 @@ import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
import type { Scene } from "@excalidraw/element"; import type { Scene } from "@excalidraw/element";
import { useApp } from "../App"; import { useApp, useExcalidrawSetAppState } from "../App";
import { InlineIcon } from "../InlineIcon"; import { InlineIcon } from "../InlineIcon";
import { SMALLEST_DELTA } from "./utils"; import { SMALLEST_DELTA } from "./utils";
@ -36,6 +36,15 @@ export type DragInputCallbackType<
property: P; property: P;
originalAppState: AppState; originalAppState: AppState;
setInputValue: (value: number) => void; setInputValue: (value: number) => void;
app: ReturnType<typeof useApp>;
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
}) => void;
export type DragFinishedCallbackType<E = ExcalidrawElement> = (props: {
app: ReturnType<typeof useApp>;
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
originalElements: readonly E[] | null;
originalAppState: AppState;
}) => void; }) => void;
interface StatsDragInputProps< interface StatsDragInputProps<
@ -54,6 +63,7 @@ interface StatsDragInputProps<
appState: AppState; appState: AppState;
/** how many px you need to drag to get 1 unit change */ /** how many px you need to drag to get 1 unit change */
sensitivity?: number; sensitivity?: number;
dragFinishedCallback?: DragFinishedCallbackType;
} }
const StatsDragInput = < const StatsDragInput = <
@ -71,8 +81,10 @@ const StatsDragInput = <
scene, scene,
appState, appState,
sensitivity = 1, sensitivity = 1,
dragFinishedCallback,
}: StatsDragInputProps<T, E>) => { }: StatsDragInputProps<T, E>) => {
const app = useApp(); const app = useApp();
const setAppState = useExcalidrawSetAppState();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null); const labelRef = useRef<HTMLDivElement>(null);
@ -137,6 +149,8 @@ const StatsDragInput = <
property, property,
originalAppState: appState, originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)), setInputValue: (value) => setInputValue(String(value)),
app,
setAppState,
}); });
app.syncActionResult({ app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -263,6 +277,8 @@ const StatsDragInput = <
scene, scene,
originalAppState, originalAppState,
setInputValue: (value) => setInputValue(String(value)), setInputValue: (value) => setInputValue(String(value)),
app,
setAppState,
}); });
stepChange = 0; stepChange = 0;
@ -287,6 +303,14 @@ const StatsDragInput = <
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}); });
// Notify implementors
dragFinishedCallback?.({
app,
setAppState,
originalElements,
originalAppState,
});
lastPointer = null; lastPointer = null;
accumulatedChange = 0; accumulatedChange = 0;
stepChange = 0; stepChange = 0;

View File

@ -2,7 +2,12 @@ import { pointFrom, type GlobalPoint } from "@excalidraw/math";
import { useMemo } from "react"; import { useMemo } from "react";
import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common"; import { MIN_WIDTH_OR_HEIGHT } from "@excalidraw/common";
import { updateBoundElements } from "@excalidraw/element"; import {
getElementsInResizingFrame,
isFrameLikeElement,
replaceAllElementsInFrame,
updateBoundElements,
} from "@excalidraw/element";
import { import {
rescalePointsInElement, rescalePointsInElement,
resizeSingleElement, resizeSingleElement,
@ -25,7 +30,10 @@ import DragInput from "./DragInput";
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils"; import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
import { getElementsInAtomicUnit } from "./utils"; import { getElementsInAtomicUnit } from "./utils";
import type { DragInputCallbackType } from "./DragInput"; import type {
DragFinishedCallbackType,
DragInputCallbackType,
} from "./DragInput";
import type { AtomicUnit } from "./utils"; import type { AtomicUnit } from "./utils";
import type { AppState } from "../../types"; import type { AppState } from "../../types";
@ -153,6 +161,8 @@ const handleDimensionChange: DragInputCallbackType<
nextValue, nextValue,
scene, scene,
property, property,
setAppState,
app,
}) => { }) => {
const elementsMap = scene.getNonDeletedElementsMap(); const elementsMap = scene.getNonDeletedElementsMap();
const atomicUnits = getAtomicUnits(originalElements, originalAppState); const atomicUnits = getAtomicUnits(originalElements, originalAppState);
@ -239,6 +249,25 @@ const handleDimensionChange: DragInputCallbackType<
shouldInformMutation: false, 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 changeInWidth = property === "width" ? accumulatedChange : 0;
const changeInHeight = property === "height" ? accumulatedChange : 0; const changeInHeight = property === "height" ? accumulatedChange : 0;
const elementsToHighlight: ExcalidrawElement[] = [];
for (const atomicUnit of atomicUnits) { for (const atomicUnit of atomicUnits) {
const elementsInUnit = getElementsInAtomicUnit( const elementsInUnit = getElementsInAtomicUnit(
@ -342,13 +372,63 @@ const handleDimensionChange: DragInputCallbackType<
shouldInformMutation: false, 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(); 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 = ({ const MultiDimension = ({
property, property,
elements, elements,
@ -396,6 +476,7 @@ const MultiDimension = ({
appState={appState} appState={appState}
property={property} property={property}
scene={scene} scene={scene}
dragFinishedCallback={handleDragFinished}
/> />
); );
}; };

View File

@ -737,3 +737,196 @@ describe("stats for multiple elements", () => {
expect(newGroupHeight).toBeCloseTo(500, 4); expect(newGroupHeight).toBeCloseTo(500, 4);
}); });
}); });
describe("frame resizing behavior", () => {
beforeEach(async () => {
localStorage.clear();
renderStaticScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<Excalidraw handleKeyboardGlobally={true} />);
API.setElements([]);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
stats = UI.queryStats();
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
it("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,
);
});
});