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
12 changed files with 310 additions and 25 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_GET_URL=https://json-dev.excalidraw.com/api/v2/
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/ VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/

View File

@ -1,5 +1,7 @@
VITE_APP_BACKEND_V2_GET_URL=https://ex.dylanbanta.com/api/v2/scenes/ MODE="production"
VITE_APP_BACKEND_V2_POST_URL=https://ex.dylanbanta.com/api/v2/scenes/
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/
VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com VITE_APP_LIBRARY_URL=https://libraries.excalidraw.com
VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries VITE_APP_LIBRARY_BACKEND=https://us-central1-excalidraw-room-persistence.cloudfunctions.net/libraries

View File

@ -34,6 +34,9 @@
<a href="https://discord.gg/UexuTaE"> <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"/> <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>
<a href="https://deepwiki.com/excalidraw/excalidraw">
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg" />
</a>
<a href="https://twitter.com/excalidraw"> <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"/> <img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
</a> </a>

View File

@ -19,7 +19,7 @@ services:
- ./:/opt/node_app/app:delegated - ./:/opt/node_app/app:delegated
- ./package.json:/opt/node_app/package.json - ./package.json:/opt/node_app/package.json
- ./yarn.lock:/opt/node_app/yarn.lock - ./yarn.lock:/opt/node_app/yarn.lock
# - notused:/opt/node_app/app/node_modules - notused:/opt/node_app/app/node_modules
# volumes: volumes:
# notused: notused:

View File

@ -926,21 +926,16 @@ const ExcalidrawWrapper = () => {
<ShareDialog <ShareDialog
collabAPI={collabAPI} collabAPI={collabAPI}
onExportToBackend={async () => { onExportToBackend={async () => {
if (!excalidrawAPI) { if (excalidrawAPI) {
return; try {
} await onExportToBackend(
try { excalidrawAPI.getSceneElements(),
const { url, errorMessage } = await exportToBackend( excalidrawAPI.getAppState(),
excalidrawAPI.getSceneElements(), excalidrawAPI.getFiles(),
excalidrawAPI.getAppState(), );
excalidrawAPI.getFiles(), } catch (error: any) {
); setErrorMessage(error.message);
if (errorMessage) {
throw new Error(errorMessage);
} }
setLatestShareableLink(url);
} catch (error: any) {
setErrorMessage(error.message);
} }
}} }}
/> />

View File

@ -41,8 +41,8 @@
"prettier": "@excalidraw/prettier-config", "prettier": "@excalidraw/prettier-config",
"scripts": { "scripts": {
"build-node": "node ./scripts/build-node.js", "build-node": "node ./scripts/build-node.js",
"build:app:docker": "vite build", "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
"build:app": "vite build", "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
"build:version": "node ../scripts/build-version.js", "build:version": "node ../scripts/build-version.js",
"build": "yarn build:app && yarn build:version", "build": "yarn build:app && yarn build:version",
"start": "yarn && vite", "start": "yarn && vite",

View File

@ -974,6 +974,25 @@ export const updateElbowArrowPoints = (
), ),
"Elbow arrow segments must be either horizontal or vertical", "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 ?? []; const fixedSegments = updates.fixedSegments ?? arrow.fixedSegments ?? [];

View File

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

View File

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

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";
@ -43,6 +46,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];
@ -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; return;
} }
const changeInWidth = property === "width" ? accumulatedChange : 0; const changeInWidth = property === "width" ? accumulatedChange : 0;
@ -230,6 +259,30 @@ const handleDimensionChange: DragInputCallbackType<
shouldMaintainAspectRatio: keepAspectRatio, 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 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,8 @@ 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; }) => void;
interface StatsDragInputProps< interface StatsDragInputProps<
@ -73,6 +75,7 @@ const StatsDragInput = <
sensitivity = 1, sensitivity = 1,
}: 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 +140,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 +268,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 +294,11 @@ const StatsDragInput = <
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}); });
// Clear frame highlighting
setAppState({
elementsToHighlight: null,
});
lastPointer = null; lastPointer = null;
accumulatedChange = 0; accumulatedChange = 0;
stepChange = 0; stepChange = 0;
@ -341,6 +353,11 @@ const StatsDragInput = <
stateRef.current.originalAppState = cloneJSON(appState); stateRef.current.originalAppState = cloneJSON(appState);
}} }}
onBlur={(event) => { onBlur={(event) => {
// Clear frame highlighting when input loses focus
setAppState({
elementsToHighlight: null,
});
if (!inputValue) { if (!inputValue) {
setInputValue(value.toString()); setInputValue(value.toString());
} else if (editable) { } else if (editable) {

View File

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