Compare commits

...

7 Commits

Author SHA1 Message Date
Mark Tolmacs
68021dc6f8
Add tests 2025-05-24 14:18:44 +02:00
Mark Tolmacs
a1af6748a4
Add highlight to chaning frame dimensions 2025-05-24 12:29:42 +02:00
Mark Tolmacs
a9f58b11b5
Working fix for stats 2025-05-24 10:29:58 +02:00
Marcel Mraz
14d512f321
Fix import.meta.env.MODE being undefined in host apps 2025-05-22 15:25:48 +02:00
Marcel Mraz
41c036e1a5
chore: Add DeepWiki badge (#9559) 2025-05-22 13:05:56 +02:00
Márk Tolmács
91d36e9b81
fix: Linear to elbow conversion crash (#9556)
* Fix linear to elbow conversion

* Add invariant check

* Add dev invariant fix

* Add arrowhead
2025-05-22 12:34:15 +02:00
Kamil Wąż
27522110df
fix: fix keybindings for arrowheads (#9557) 2025-05-22 09:47:41 +02:00
9 changed files with 294 additions and 4 deletions

View File

@ -1,3 +1,5 @@
MODE="development"
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/

View File

@ -1,3 +1,5 @@
MODE="production"
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/

View File

@ -34,6 +34,9 @@
<a href="https://discord.gg/UexuTaE">
<img alt="Chat on Discord" src="https://img.shields.io/discord/723672430744174682?color=738ad6&label=Chat%20on%20Discord&logo=discord&logoColor=ffffff&widge=false"/>
</a>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</a>
<a href="https://twitter.com/excalidraw">
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a>

View File

@ -974,6 +974,25 @@ export const updateElbowArrowPoints = (
),
"Elbow arrow segments must be either horizontal or vertical",
);
invariant(
updates.fixedSegments?.find(
(segment) =>
segment.index === 1 &&
pointsEqual(segment.start, (updates.points ?? arrow.points)[0]),
) == null &&
updates.fixedSegments?.find(
(segment) =>
segment.index === (updates.points ?? arrow.points).length - 1 &&
pointsEqual(
segment.end,
(updates.points ?? arrow.points)[
(updates.points ?? arrow.points).length - 1
],
),
) == null,
"The first and last segments cannot be fixed",
);
}
const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];

View File

@ -1483,13 +1483,13 @@ const getArrowheadOptions = (flip: boolean) => {
value: "crowfoot_one",
text: t("labels.arrowhead_crowfoot_one"),
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
keyBinding: "c",
keyBinding: "x",
},
{
value: "crowfoot_many",
text: t("labels.arrowhead_crowfoot_many"),
icon: <ArrowheadCrowfootIcon flip={flip} />,
keyBinding: "x",
keyBinding: "c",
},
{
value: "crowfoot_one_or_many",

View File

@ -564,7 +564,7 @@ export const convertElementTypes = (
continue;
}
const fixedSegments: FixedSegment[] = [];
for (let i = 0; i < nextPoints.length - 1; i++) {
for (let i = 1; i < nextPoints.length - 2; i++) {
fixedSegments.push({
start: nextPoints[i],
end: nextPoints[i + 1],
@ -581,6 +581,7 @@ export const convertElementTypes = (
);
mutateElement(element, app.scene.getNonDeletedElementsMap(), {
...updates,
endArrowhead: "arrow",
});
} else {
// if we're converting to non-elbow linear element, check if

View File

@ -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";
@ -43,6 +46,8 @@ const handleDimensionChange: DragInputCallbackType<
originalAppState,
instantChange,
scene,
app,
setAppState,
}) => {
const elementsMap = scene.getNonDeletedElementsMap();
const origElement = originalElements[0];
@ -184,6 +189,30 @@ 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);
setAppState({
...app.state,
elementsToHighlight: nextElementsInFrame,
});
}
return;
}
const changeInWidth = property === "width" ? accumulatedChange : 0;
@ -230,6 +259,30 @@ const handleDimensionChange: DragInputCallbackType<
shouldMaintainAspectRatio: keepAspectRatio,
},
);
// 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);
setAppState({
...app.state,
elementsToHighlight: nextElementsInFrame,
});
}
}
};

View File

@ -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,8 @@ export type DragInputCallbackType<
property: P;
originalAppState: AppState;
setInputValue: (value: number) => void;
app: ReturnType<typeof useApp>;
setAppState: ReturnType<typeof useExcalidrawSetAppState>;
}) => void;
interface StatsDragInputProps<
@ -73,6 +75,7 @@ const StatsDragInput = <
sensitivity = 1,
}: StatsDragInputProps<T, E>) => {
const app = useApp();
const setAppState = useExcalidrawSetAppState();
const inputRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLDivElement>(null);
@ -137,6 +140,8 @@ const StatsDragInput = <
property,
originalAppState: appState,
setInputValue: (value) => setInputValue(String(value)),
app,
setAppState,
});
app.syncActionResult({
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
@ -263,6 +268,8 @@ const StatsDragInput = <
scene,
originalAppState,
setInputValue: (value) => setInputValue(String(value)),
app,
setAppState,
});
stepChange = 0;
@ -287,6 +294,11 @@ const StatsDragInput = <
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
});
// Clear frame highlighting
setAppState({
elementsToHighlight: null,
});
lastPointer = null;
accumulatedChange = 0;
stepChange = 0;
@ -341,6 +353,11 @@ const StatsDragInput = <
stateRef.current.originalAppState = cloneJSON(appState);
}}
onBlur={(event) => {
// Clear frame highlighting when input loses focus
setAppState({
elementsToHighlight: null,
});
if (!inputValue) {
setInputValue(value.toString());
} else if (editable) {

View File

@ -728,3 +728,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(<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,
);
});
});