refactor: export everything from @excalidraw/element, don't import from subpaths (#9466)

* Don't import from subpaths

* Fix tests, move related tests to element
This commit is contained in:
Marcel Mraz
2025-05-09 23:01:33 +02:00
committed by GitHub
parent a8ebe514da
commit 7e41026812
129 changed files with 517 additions and 541 deletions
+6 -11
View File
@@ -9,21 +9,18 @@ import {
toArray,
} from "@excalidraw/common";
import { isNonDeletedElement } from "@excalidraw/element";
import { isFrameLikeElement } from "@excalidraw/element/typeChecks";
import { getElementsInGroup } from "@excalidraw/element/groups";
import { isFrameLikeElement } from "@excalidraw/element";
import { getElementsInGroup } from "@excalidraw/element";
import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
} from "@excalidraw/element";
import { getSelectedElements } from "@excalidraw/element/selection";
import { getSelectedElements } from "@excalidraw/element";
import {
mutateElement,
type ElementUpdate,
} from "@excalidraw/element/mutateElement";
import { mutateElement, type ElementUpdate } from "@excalidraw/element";
import type {
ExcalidrawElement,
@@ -108,7 +105,7 @@ const hashSelectionOpts = (
// in our codebase
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
class Scene {
export class Scene {
// ---------------------------------------------------------------------------
// instance methods/props
// ---------------------------------------------------------------------------
@@ -457,5 +454,3 @@ class Scene {
return element;
}
}
export default Scene;
+1 -1
View File
@@ -2,7 +2,7 @@ import { updateBoundElements } from "./binding";
import { getCommonBoundingBox } from "./bounds";
import { getMaximumGroups } from "./groups";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds";
import type { ExcalidrawElement } from "./types";
+1 -1
View File
@@ -66,7 +66,7 @@ import {
import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
import { updateElbowArrowPoints } from "./elbowArrow";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ElementUpdate } from "./mutateElement";
+1 -1
View File
@@ -49,7 +49,7 @@ import { getNonDeletedGroupIds } from "./groups";
import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex";
import Scene from "./Scene";
import { Scene } from "./Scene";
import type { BindableProp, BindingProp } from "./binding";
+1 -1
View File
@@ -26,7 +26,7 @@ import {
isTextElement,
} from "./typeChecks";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type { ExcalidrawElement } from "./types";
+1 -1
View File
@@ -39,7 +39,7 @@ import {
type OrderedExcalidrawElement,
} from "./types";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
type LinkDirection = "up" | "right" | "down" | "left";
+44
View File
@@ -72,3 +72,47 @@ export const clearElementsForExport = (
export const clearElementsForLocalStorage = (
elements: readonly ExcalidrawElement[],
) => _clearElements(elements);
export * from "./align";
export * from "./binding";
export * from "./bounds";
export * from "./collision";
export * from "./comparisons";
export * from "./containerCache";
export * from "./cropElement";
export * from "./delta";
export * from "./distance";
export * from "./distribute";
export * from "./dragElements";
export * from "./duplicate";
export * from "./elbowArrow";
export * from "./elementLink";
export * from "./embeddable";
export * from "./flowchart";
export * from "./fractionalIndex";
export * from "./frame";
export * from "./groups";
export * from "./heading";
export * from "./image";
export * from "./linearElementEditor";
export * from "./mutateElement";
export * from "./newElement";
export * from "./renderElement";
export * from "./resizeElements";
export * from "./resizeTest";
export * from "./Scene";
export * from "./selection";
export * from "./Shape";
export * from "./ShapeCache";
export * from "./shapes";
export * from "./showSelectedShapeActions";
export * from "./sizeHelpers";
export * from "./sortElements";
export * from "./store";
export * from "./textElement";
export * from "./textMeasurements";
export * from "./textWrapping";
export * from "./transformHandles";
export * from "./typeChecks";
export * from "./utils";
export * from "./zindex";
+2 -2
View File
@@ -20,7 +20,7 @@ import {
tupleToCoors,
} from "@excalidraw/common";
import type { Store } from "@excalidraw/element/store";
import type { Store } from "@excalidraw/element";
import type { Radians } from "@excalidraw/math";
@@ -67,7 +67,7 @@ import {
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { Bounds } from "./bounds";
import type {
+1 -1
View File
@@ -57,7 +57,7 @@ import {
import { isInGroup } from "./groups";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { BoundingBox } from "./bounds";
import type {
+1 -1
View File
@@ -30,7 +30,7 @@ import {
isTextElement,
} from "./typeChecks";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { MaybeTransformHandleType } from "./transformHandles";
import type {
+1 -1
View File
@@ -10,7 +10,7 @@ import { syncMovedIndices } from "./fractionalIndex";
import { getSelectedElements } from "./selection";
import type Scene from "./Scene";
import type { Scene } from "./Scene";
import type { ExcalidrawElement, ExcalidrawFrameLikeElement } from "./types";
@@ -0,0 +1,57 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Test Linear Elements > Test bound text element > should bind text to arrow when clicked on arrow and enter pressed 1`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should bind text to arrow when double clicked 1`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should match styles for text editor 1`] = `
<textarea
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
tabindex="0"
wrap="off"
/>
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized 2`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text and bounding box correctly when 3 pointer arrow element resized 6`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text correctly when 2 pointer linear element resized 2`] = `
"Online whiteboard
collaboration made
easy"
`;
exports[`Test Linear Elements > Test bound text element > should resize and position the bound text correctly when 2 pointer linear element resized 5`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 1`] = `
"Online whiteboard
collaboration made easy"
`;
exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = `
"Online whiteboard
collaboration made
easy"
`;
+359
View File
@@ -0,0 +1,359 @@
import React from "react";
import { vi } from "vitest";
import { KEYS, cloneJSON } from "@excalidraw/common";
import {
Excalidraw,
exportToCanvas,
exportToSvg,
} from "@excalidraw/excalidraw";
import {
actionFlipHorizontal,
actionFlipVertical,
} from "@excalidraw/excalidraw/actions";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { Keyboard, Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
import {
act,
GlobalTestState,
render,
unmountComponent,
} from "@excalidraw/excalidraw/tests/test-utils";
import type { NormalizedZoomValue } from "@excalidraw/excalidraw/types";
import { duplicateElement } from "../src/duplicate";
import type { ExcalidrawImageElement, ImageCrop } from "../src/types";
const { h } = window;
const mouse = new Pointer("mouse");
beforeEach(async () => {
unmountComponent();
mouse.reset();
localStorage.clear();
sessionStorage.clear();
vi.clearAllMocks();
Object.assign(document, {
elementFromPoint: () => GlobalTestState.canvas,
});
await render(<Excalidraw autoFocus={true} handleKeyboardGlobally={true} />);
API.setAppState({
zoom: {
value: 1 as NormalizedZoomValue,
},
});
const image = API.createElement({ type: "image", width: 200, height: 100 });
API.setElements([image]);
API.setAppState({
selectedElementIds: {
[image.id]: true,
},
});
});
const generateRandomNaturalWidthAndHeight = (image: ExcalidrawImageElement) => {
const initialWidth = image.width;
const initialHeight = image.height;
const scale = 1 + Math.random() * 5;
return {
naturalWidth: initialWidth * scale,
naturalHeight: initialHeight * scale,
};
};
const compareCrops = (cropA: ImageCrop, cropB: ImageCrop) => {
(Object.keys(cropA) as [keyof ImageCrop]).forEach((key) => {
const propA = cropA[key];
const propB = cropB[key];
expect(propA as number).toBeCloseTo(propB as number);
});
};
describe("Enter and leave the crop editor", () => {
it("enter the editor by double clicking", () => {
const image = h.elements[0];
expect(h.state.croppingElementId).toBe(null);
mouse.doubleClickOn(image);
expect(h.state.croppingElementId).not.toBe(null);
expect(h.state.croppingElementId).toBe(image.id);
});
it("enter the editor by pressing enter", () => {
const image = h.elements[0];
expect(h.state.croppingElementId).toBe(null);
Keyboard.keyDown(KEYS.ENTER);
expect(h.state.croppingElementId).not.toBe(null);
expect(h.state.croppingElementId).toBe(image.id);
});
it("leave the editor by clicking outside", () => {
const image = h.elements[0];
Keyboard.keyDown(KEYS.ENTER);
expect(h.state.croppingElementId).not.toBe(null);
mouse.click(image.x - 20, image.y - 20);
expect(h.state.croppingElementId).toBe(null);
});
it("leave the editor by pressing escape", () => {
const image = h.elements[0];
mouse.doubleClickOn(image);
expect(h.state.croppingElementId).not.toBe(null);
Keyboard.keyDown(KEYS.ESCAPE);
expect(h.state.croppingElementId).toBe(null);
});
});
describe("Crop an image", () => {
it("Cropping changes the dimension", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 2, 0]);
expect(image.width).toBeLessThan(initialWidth);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight / 2]);
expect(image.height).toBeLessThan(initialHeight);
});
it("Cropping has minimal sizes", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth, 0]);
expect(image.width).toBeLessThan(initialWidth);
expect(image.width).toBeGreaterThan(0);
UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth, 0]);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, initialHeight]);
expect(image.height).toBeLessThan(initialHeight);
expect(image.height).toBeGreaterThan(0);
});
it("Preserve aspect ratio", async () => {
let image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "w", naturalWidth, naturalHeight, [initialWidth / 3, 0]);
let resizedWidth = image.width;
let resizedHeight = image.height;
// max height, cropping should not change anything
UI.crop(
image,
"w",
naturalWidth,
naturalHeight,
[-initialWidth / 3, 0],
true,
);
expect(image.width).toBeCloseTo(resizedWidth, 10);
expect(image.height).toBeCloseTo(resizedHeight, 10);
// re-crop to initial state
UI.crop(image, "w", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
// change crop height and width
UI.crop(image, "s", naturalWidth, naturalHeight, [0, -initialHeight / 2]);
UI.crop(image, "e", naturalWidth, naturalHeight, [-initialWidth / 3, 0]);
resizedWidth = image.width;
resizedHeight = image.height;
// test corner handle aspect ratio preserving
UI.crop(image, "se", naturalWidth, naturalHeight, [initialWidth, 0], true);
expect(image.width / image.height).toBe(resizedWidth / resizedHeight);
expect(image.width).toBeLessThanOrEqual(initialWidth + 0.0001);
expect(image.height).toBeLessThanOrEqual(initialHeight + 0.0001);
// reset
image = API.createElement({ type: "image", width: 200, height: 100 });
API.setElements([image]);
API.setAppState({
selectedElementIds: {
[image.id]: true,
},
});
// 50 x 50 square
UI.crop(image, "nw", naturalWidth, naturalHeight, [150, 50]);
UI.crop(image, "n", naturalWidth, naturalHeight, [0, -100], true);
expect(image.width).toBeCloseTo(image.height);
// image is at the corner, not space to its right to expand, should not be able to resize
expect(image.height).toBeCloseTo(50);
UI.crop(image, "nw", naturalWidth, naturalHeight, [-150, -100], true);
expect(image.width).toBeCloseTo(image.height);
// max height should be reached
expect(image.height).toBeCloseTo(initialHeight);
expect(image.width).toBeCloseTo(initialHeight);
});
});
describe("Cropping and other features", async () => {
it("Cropping works independently of duplication", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "nw", naturalWidth, naturalHeight, [
initialWidth / 2,
initialHeight / 2,
]);
Keyboard.keyDown(KEYS.ESCAPE);
const duplicatedImage = duplicateElement(null, new Map(), image);
act(() => {
h.app.scene.insertElement(duplicatedImage);
});
expect(duplicatedImage.width).toBe(image.width);
expect(duplicatedImage.height).toBe(image.height);
UI.crop(duplicatedImage, "nw", naturalWidth, naturalHeight, [
-initialWidth / 2,
-initialHeight / 2,
]);
expect(duplicatedImage.width).toBe(initialWidth);
expect(duplicatedImage.height).toBe(initialHeight);
const resizedWidth = image.width;
const resizedHeight = image.height;
expect(image.width).not.toBe(duplicatedImage.width);
expect(image.height).not.toBe(duplicatedImage.height);
UI.crop(duplicatedImage, "se", naturalWidth, naturalHeight, [
-initialWidth / 1.5,
-initialHeight / 1.5,
]);
expect(duplicatedImage.width).not.toBe(initialWidth);
expect(image.width).toBe(resizedWidth);
expect(duplicatedImage.height).not.toBe(initialHeight);
expect(image.height).toBe(resizedHeight);
});
it("Resizing should not affect crop", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
UI.crop(image, "nw", naturalWidth, naturalHeight, [
initialWidth / 2,
initialHeight / 2,
]);
const cropBeforeResizing = image.crop;
const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
expect(cropBeforeResizing).not.toBe(null);
UI.crop(image, "e", naturalWidth, naturalHeight, [200, 0]);
expect(cropBeforeResizing).toBe(image.crop);
compareCrops(cropBeforeResizingCloned, image.crop!);
UI.resize(image, "s", [0, -100]);
expect(cropBeforeResizing).toBe(image.crop);
compareCrops(cropBeforeResizingCloned, image.crop!);
UI.resize(image, "ne", [-50, -50]);
expect(cropBeforeResizing).toBe(image.crop);
compareCrops(cropBeforeResizingCloned, image.crop!);
});
it("Flipping does not change crop", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
mouse.doubleClickOn(image);
expect(h.state.croppingElementId).not.toBe(null);
UI.crop(image, "nw", naturalWidth, naturalHeight, [
initialWidth / 2,
initialHeight / 2,
]);
Keyboard.keyDown(KEYS.ESCAPE);
const cropBeforeResizing = image.crop;
const cropBeforeResizingCloned = cloneJSON(image.crop) as ImageCrop;
API.executeAction(actionFlipHorizontal);
expect(image.crop).toBe(cropBeforeResizing);
compareCrops(cropBeforeResizingCloned, image.crop!);
API.executeAction(actionFlipVertical);
expect(image.crop).toBe(cropBeforeResizing);
compareCrops(cropBeforeResizingCloned, image.crop!);
});
it("Exports should preserve crops", async () => {
const image = h.elements[0] as ExcalidrawImageElement;
const initialWidth = image.width;
const initialHeight = image.height;
const { naturalWidth, naturalHeight } =
generateRandomNaturalWidthAndHeight(image);
mouse.doubleClickOn(image);
expect(h.state.croppingElementId).not.toBe(null);
UI.crop(image, "nw", naturalWidth, naturalHeight, [
initialWidth / 2,
initialHeight / 4,
]);
Keyboard.keyDown(KEYS.ESCAPE);
const widthToHeightRatio = image.width / image.height;
const canvas = await exportToCanvas({
elements: [image],
// @ts-ignore
appState: h.state,
files: h.app.files,
exportPadding: 0,
});
const exportedCanvasRatio = canvas.width / canvas.height;
expect(widthToHeightRatio).toBeCloseTo(exportedCanvasRatio);
const svg = await exportToSvg({
elements: [image],
// @ts-ignore
appState: h.state,
files: h.app.files,
exportPadding: 0,
});
const svgWidth = svg.getAttribute("width");
const svgHeight = svg.getAttribute("height");
expect(svgWidth).toBeDefined();
expect(svgHeight).toBeDefined();
const exportedSvgRatio = Number(svgWidth) / Number(svgHeight);
expect(widthToHeightRatio).toBeCloseTo(exportedSvgRatio);
});
});
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ObservedAppState } from "@excalidraw/excalidraw/types";
import type { LinearElementEditor } from "@excalidraw/element/linearElementEditor";
import type { LinearElementEditor } from "@excalidraw/element";
import { AppStateDelta } from "../src/delta";
+1 -1
View File
@@ -22,7 +22,7 @@ import type { LocalPoint } from "@excalidraw/math";
import { bindLinearElement } from "../src/binding";
import Scene from "../src/Scene";
import { Scene } from "../src/Scene";
import type {
ExcalidrawArrowElement,
@@ -7,9 +7,9 @@ import {
syncInvalidIndices,
syncMovedIndices,
validateFractionalIndices,
} from "@excalidraw/element/fractionalIndex";
} from "@excalidraw/element";
import { deepCopyElement } from "@excalidraw/element/duplicate";
import { deepCopyElement } from "@excalidraw/element";
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
import { mutateElement } from "@excalidraw/element/mutateElement";
import { mutateElement } from "@excalidraw/element";
import { normalizeElementOrder } from "../src/sortElements";