mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Partialy fixing element package
This commit is contained in:
parent
5e68895709
commit
e2c2218f62
45 changed files with 383 additions and 206 deletions
143
packages/element/tests/bounds.test.ts
Normal file
143
packages/element/tests/bounds.test.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { arrayToMap, ROUNDNESS } from "@excalidraw/common";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { getElementAbsoluteCoords, getElementBounds } from "../src/bounds";
|
||||
|
||||
import type { ExcalidrawElement, ExcalidrawLinearElement } from "../src/types";
|
||||
|
||||
const _ce = ({
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
a,
|
||||
t,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
a?: number;
|
||||
t?: string;
|
||||
}) =>
|
||||
({
|
||||
type: t || "rectangle",
|
||||
strokeColor: "#000",
|
||||
backgroundColor: "#000",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 0,
|
||||
opacity: 1,
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
angle: a,
|
||||
} as ExcalidrawElement);
|
||||
|
||||
describe("getElementAbsoluteCoords", () => {
|
||||
it("test x1 coordinate", () => {
|
||||
const element = _ce({ x: 10, y: 20, w: 10, h: 0 });
|
||||
const [x1] = getElementAbsoluteCoords(element, arrayToMap([element]));
|
||||
expect(x1).toEqual(10);
|
||||
});
|
||||
|
||||
it("test x2 coordinate", () => {
|
||||
const element = _ce({ x: 10, y: 20, w: 10, h: 0 });
|
||||
const [, , x2] = getElementAbsoluteCoords(element, arrayToMap([element]));
|
||||
expect(x2).toEqual(20);
|
||||
});
|
||||
|
||||
it("test y1 coordinate", () => {
|
||||
const element = _ce({ x: 0, y: 10, w: 0, h: 10 });
|
||||
const [, y1] = getElementAbsoluteCoords(element, arrayToMap([element]));
|
||||
expect(y1).toEqual(10);
|
||||
});
|
||||
|
||||
it("test y2 coordinate", () => {
|
||||
const element = _ce({ x: 0, y: 10, w: 0, h: 10 });
|
||||
const [, , , y2] = getElementAbsoluteCoords(element, arrayToMap([element]));
|
||||
expect(y2).toEqual(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getElementBounds", () => {
|
||||
it("rectangle", () => {
|
||||
const element = _ce({
|
||||
x: 40,
|
||||
y: 30,
|
||||
w: 20,
|
||||
h: 10,
|
||||
a: Math.PI / 4,
|
||||
t: "rectangle",
|
||||
});
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||
expect(x1).toEqual(39.39339828220179);
|
||||
expect(y1).toEqual(24.393398282201787);
|
||||
expect(x2).toEqual(60.60660171779821);
|
||||
expect(y2).toEqual(45.60660171779821);
|
||||
});
|
||||
|
||||
it("diamond", () => {
|
||||
const element = _ce({
|
||||
x: 40,
|
||||
y: 30,
|
||||
w: 20,
|
||||
h: 10,
|
||||
a: Math.PI / 4,
|
||||
t: "diamond",
|
||||
});
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||
|
||||
expect(x1).toEqual(42.928932188134524);
|
||||
expect(y1).toEqual(27.928932188134524);
|
||||
expect(x2).toEqual(57.071067811865476);
|
||||
expect(y2).toEqual(42.071067811865476);
|
||||
});
|
||||
|
||||
it("ellipse", () => {
|
||||
const element = _ce({
|
||||
x: 40,
|
||||
y: 30,
|
||||
w: 20,
|
||||
h: 10,
|
||||
a: Math.PI / 4,
|
||||
t: "ellipse",
|
||||
});
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||
expect(x1).toEqual(42.09430584957905);
|
||||
expect(y1).toEqual(27.09430584957905);
|
||||
expect(x2).toEqual(57.90569415042095);
|
||||
expect(y2).toEqual(42.90569415042095);
|
||||
});
|
||||
|
||||
it("curved line", () => {
|
||||
const element = {
|
||||
..._ce({
|
||||
t: "line",
|
||||
x: 449.58203125,
|
||||
y: 186.0625,
|
||||
w: 170.12890625,
|
||||
h: 92.48828125,
|
||||
a: 0.6447741904932416,
|
||||
}),
|
||||
points: [
|
||||
pointFrom<LocalPoint>(0, 0),
|
||||
pointFrom<LocalPoint>(67.33984375, 92.48828125),
|
||||
pointFrom<LocalPoint>(-102.7890625, 52.15625),
|
||||
],
|
||||
} as ExcalidrawLinearElement;
|
||||
|
||||
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
|
||||
expect(x1).toEqual(360.3176068760539);
|
||||
expect(y1).toEqual(185.90654264413516);
|
||||
expect(x2).toEqual(480.87005902729743);
|
||||
expect(y2).toEqual(320.4751269334226);
|
||||
});
|
||||
});
|
414
packages/element/tests/elbowArrow.test.tsx
Normal file
414
packages/element/tests/elbowArrow.test.tsx
Normal file
|
@ -0,0 +1,414 @@
|
|||
import { ARROW_TYPE } from "@excalidraw/common";
|
||||
import { pointFrom } from "@excalidraw/math";
|
||||
import { Excalidraw, mutateElement } from "@excalidraw/excalidraw";
|
||||
|
||||
import Scene from "@excalidraw/excalidraw/scene/Scene";
|
||||
import { actionSelectAll } from "@excalidraw/excalidraw/actions";
|
||||
import { actionDuplicateSelection } from "@excalidraw/excalidraw/actions/actionDuplicateSelection";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { Pointer, UI } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
GlobalTestState,
|
||||
queryByTestId,
|
||||
render,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { bindLinearElement } from "@excalidraw/element/binding";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import "../../utils/test-utils";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
describe("elbow arrow segment move", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
});
|
||||
|
||||
it("can move the second segment of a fully connected elbow arrow", () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -100,
|
||||
y: -50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 200,
|
||||
y: 150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(0, 0);
|
||||
mouse.click();
|
||||
mouse.moveTo(200, 200);
|
||||
mouse.click();
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(100, 100);
|
||||
mouse.down();
|
||||
mouse.moveTo(115, 100);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawElbowArrowElement;
|
||||
|
||||
expect(h.state.selectedElementIds).toEqual({ [arrow.id]: true });
|
||||
expect(arrow.fixedSegments?.length).toBe(1);
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[110, 0],
|
||||
[110, 200],
|
||||
[190, 200],
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(105, 74.275);
|
||||
mouse.doubleClick();
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[110, 0],
|
||||
[110, 200],
|
||||
[190, 200],
|
||||
]);
|
||||
});
|
||||
|
||||
it("can move the second segment of an unconnected elbow arrow", () => {
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(0, 0);
|
||||
mouse.click();
|
||||
mouse.moveTo(250, 200);
|
||||
mouse.click();
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(125, 100);
|
||||
mouse.down();
|
||||
mouse.moveTo(130, 100);
|
||||
mouse.up();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[130, 0],
|
||||
[130, 200],
|
||||
[250, 200],
|
||||
]);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(130, 100);
|
||||
mouse.doubleClick();
|
||||
|
||||
expect(arrow.points).toCloselyEqualPoints([
|
||||
[0, 0],
|
||||
[125, 0],
|
||||
[125, 200],
|
||||
[250, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elbow arrow routing", () => {
|
||||
it("can properly generate orthogonal arrow points", () => {
|
||||
const scene = new Scene();
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(arrow);
|
||||
mutateElement(arrow, {
|
||||
points: [
|
||||
pointFrom<LocalPoint>(-45 - arrow.x, -100.1 - arrow.y),
|
||||
pointFrom<LocalPoint>(45 - arrow.x, 99.9 - arrow.y),
|
||||
],
|
||||
});
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[0, 100],
|
||||
[90, 100],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.x).toEqual(-45);
|
||||
expect(arrow.y).toEqual(-100.1);
|
||||
expect(arrow.width).toEqual(90);
|
||||
expect(arrow.height).toEqual(200);
|
||||
});
|
||||
it("can generate proper points for bound elbow arrow", () => {
|
||||
const scene = new Scene();
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}) as ExcalidrawBindableElement;
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
}) as ExcalidrawBindableElement;
|
||||
const arrow = API.createElement({
|
||||
type: "arrow",
|
||||
elbowed: true,
|
||||
x: -45,
|
||||
y: -100.1,
|
||||
width: 90,
|
||||
height: 200,
|
||||
points: [pointFrom(0, 0), pointFrom(90, 200)],
|
||||
}) as ExcalidrawElbowArrowElement;
|
||||
scene.insertElement(rectangle1);
|
||||
scene.insertElement(rectangle2);
|
||||
scene.insertElement(arrow);
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
bindLinearElement(arrow, rectangle1, "start", elementsMap);
|
||||
bindLinearElement(arrow, rectangle2, "end", elementsMap);
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
mutateElement(arrow, {
|
||||
points: [pointFrom<LocalPoint>(0, 0), pointFrom<LocalPoint>(90, 200)],
|
||||
});
|
||||
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elbow arrow ui", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
});
|
||||
|
||||
it("can follow bound shapes", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
expect(h.state.currentItemArrowType).toBe(ARROW_TYPE.elbow);
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(arrow.type).toBe("arrow");
|
||||
expect(arrow.elbowed).toBe(true);
|
||||
expect(arrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
|
||||
it("can follow bound rotated shapes", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
mouse.click(51, 51);
|
||||
|
||||
const inputAngle = UI.queryStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
UI.updateInput(inputAngle, String("40"));
|
||||
|
||||
expect(arrow.points.map((point) => point.map(Math.round))).toEqual([
|
||||
[0, 0],
|
||||
[35, 0],
|
||||
[35, 165],
|
||||
[103, 165],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps arrow shape when the whole set of arrow and bindables are duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionSelectAll);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(6);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[2] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("keeps arrow shape when only the bound arrow is duplicated", async () => {
|
||||
UI.createElement("rectangle", {
|
||||
x: -150,
|
||||
y: -150,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
UI.createElement("rectangle", {
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
UI.clickTool("arrow");
|
||||
UI.clickOnTestId("elbow-arrow");
|
||||
|
||||
mouse.reset();
|
||||
mouse.moveTo(-43, -99);
|
||||
mouse.click();
|
||||
mouse.moveTo(43, 99);
|
||||
mouse.click();
|
||||
|
||||
const arrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
const originalArrowId = arrow.id;
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
expect(arrow.endBinding).not.toBe(null);
|
||||
|
||||
act(() => {
|
||||
h.app.actionManager.executeAction(actionDuplicateSelection);
|
||||
});
|
||||
|
||||
expect(h.elements.length).toEqual(4);
|
||||
|
||||
const duplicatedArrow = h.scene.getSelectedElements(
|
||||
h.state,
|
||||
)[0] as ExcalidrawArrowElement;
|
||||
|
||||
expect(duplicatedArrow.id).not.toBe(originalArrowId);
|
||||
expect(duplicatedArrow.type).toBe("arrow");
|
||||
expect(duplicatedArrow.elbowed).toBe(true);
|
||||
expect(duplicatedArrow.points).toEqual([
|
||||
[0, 0],
|
||||
[45, 0],
|
||||
[45, 200],
|
||||
[90, 200],
|
||||
]);
|
||||
});
|
||||
});
|
408
packages/element/tests/flowchart.test.tsx
Normal file
408
packages/element/tests/flowchart.test.tsx
Normal file
|
@ -0,0 +1,408 @@
|
|||
import { KEYS } from "@excalidraw/common";
|
||||
import { reseed } from "@excalidraw/common";
|
||||
|
||||
import { Excalidraw } from "@excalidraw/excalidraw";
|
||||
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Keyboard, Pointer } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import {
|
||||
render,
|
||||
unmountComponent,
|
||||
} from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
unmountComponent();
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
reseed(7);
|
||||
mouse.reset();
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
h.state.width = 1000;
|
||||
h.state.height = 1000;
|
||||
|
||||
// The bounds of hand-drawn linear elements may change after flipping, so
|
||||
// removing this style for testing
|
||||
UI.clickTool("arrow");
|
||||
UI.clickByTitle("Architect");
|
||||
UI.clickTool("selection");
|
||||
});
|
||||
|
||||
describe("flow chart creation", () => {
|
||||
beforeEach(() => {
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
});
|
||||
|
||||
// multiple at once
|
||||
it("create multiple successor nodes at once", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||
});
|
||||
|
||||
it("when directions are changed, only the last same directions will apply", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(7);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||
});
|
||||
|
||||
it("when escaped, no nodes will be created", () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
|
||||
Keyboard.keyPress(KEYS.ESCAPE);
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(1);
|
||||
});
|
||||
|
||||
it("create nodes one at a time", () => {
|
||||
const initialNode = h.elements[0];
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(2);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(1);
|
||||
|
||||
const firstChildNode = h.elements.filter(
|
||||
(el) => el.type === "rectangle" && el.id !== initialNode.id,
|
||||
)[0];
|
||||
expect(firstChildNode).not.toBe(null);
|
||||
expect(firstChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
API.setSelectedElements([initialNode]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(3);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(2);
|
||||
|
||||
const secondChildNode = h.elements.filter(
|
||||
(el) =>
|
||||
el.type === "rectangle" &&
|
||||
el.id !== initialNode.id &&
|
||||
el.id !== firstChildNode.id,
|
||||
)[0];
|
||||
expect(secondChildNode).not.toBe(null);
|
||||
expect(secondChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
API.setSelectedElements([initialNode]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.length).toBe(7);
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(4);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(3);
|
||||
|
||||
const thirdChildNode = h.elements.filter(
|
||||
(el) =>
|
||||
el.type === "rectangle" &&
|
||||
el.id !== initialNode.id &&
|
||||
el.id !== firstChildNode.id &&
|
||||
el.id !== secondChildNode.id,
|
||||
)[0];
|
||||
|
||||
expect(thirdChildNode).not.toBe(null);
|
||||
expect(thirdChildNode.id).toBe(Object.keys(h.state.selectedElementIds)[0]);
|
||||
|
||||
expect(firstChildNode.x).toBe(secondChildNode.x);
|
||||
expect(secondChildNode.x).toBe(thirdChildNode.x);
|
||||
});
|
||||
});
|
||||
|
||||
describe("flow chart navigation", () => {
|
||||
it("single node at each level", () => {
|
||||
/**
|
||||
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
expect(h.elements.filter((el) => el.type === "rectangle").length).toBe(5);
|
||||
expect(h.elements.filter((el) => el.type === "arrow").length).toBe(4);
|
||||
|
||||
// all the way to the left, gets us to the first node
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
|
||||
// all the way to the right, gets us to the last node
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
expect(rightMostNode);
|
||||
expect(rightMostNode.type).toBe("rectangle");
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
});
|
||||
|
||||
it("multiple nodes at each level", () => {
|
||||
/**
|
||||
* from the perspective of the first node, there're four layers, and
|
||||
* there are four nodes at the second layer
|
||||
*
|
||||
* -> ▨
|
||||
* ▨ -> ▨ -> ▨ -> ▨ -> ▨
|
||||
* -> ▨
|
||||
* -> ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
const secondNode = h.elements[1];
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
// because of same level cycling,
|
||||
// going right five times should take us back to the second node again
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[secondNode.id]).toBe(true);
|
||||
|
||||
// from the second node, going right three times should take us to the rightmost node
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
});
|
||||
|
||||
it("take the most obvious link when possible", () => {
|
||||
/**
|
||||
* ▨ → ▨ ▨ → ▨
|
||||
* ↓ ↑
|
||||
* ▨ → ▨
|
||||
*/
|
||||
|
||||
API.clearSelection();
|
||||
const rectangle = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
API.setElements([rectangle]);
|
||||
API.setSelectedElements([rectangle]);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.CTRL_OR_CMD);
|
||||
|
||||
// last node should be the one that's selected
|
||||
const rightMostNode = h.elements[h.elements.length - 2];
|
||||
expect(rightMostNode.type).toBe("rectangle");
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).toBe(true);
|
||||
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
Keyboard.keyPress(KEYS.ARROW_LEFT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
|
||||
expect(h.state.selectedElementIds[rectangle.id]).toBe(true);
|
||||
|
||||
// going any direction takes us to the predecessor as well
|
||||
const predecessorToRightMostNode = h.elements[h.elements.length - 4];
|
||||
expect(predecessorToRightMostNode.type).toBe("rectangle");
|
||||
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_RIGHT);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_UP);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
API.setSelectedElements([rightMostNode]);
|
||||
Keyboard.withModifierKeys({ alt: true }, () => {
|
||||
Keyboard.keyPress(KEYS.ARROW_DOWN);
|
||||
});
|
||||
Keyboard.keyUp(KEYS.ALT);
|
||||
expect(h.state.selectedElementIds[rightMostNode.id]).not.toBe(true);
|
||||
expect(h.state.selectedElementIds[predecessorToRightMostNode.id]).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
376
packages/element/tests/newElement.test.ts
Normal file
376
packages/element/tests/newElement.test.ts
Normal file
|
@ -0,0 +1,376 @@
|
|||
import { pointFrom } from "@excalidraw/math";
|
||||
|
||||
import { FONT_FAMILY, ROUNDNESS, isPrimitive } from "@excalidraw/common";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import { mutateElement } from "../src/mutateElement";
|
||||
import { duplicateElement, duplicateElements } from "../src/newElement";
|
||||
|
||||
import type { ExcalidrawLinearElement } from "../src/types";
|
||||
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
|
||||
expect(clone[key]).not.toBe(source[key]);
|
||||
if (source[key]) {
|
||||
assertCloneObjects(source[key], clone[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
describe("duplicating single elements", () => {
|
||||
it("clones arrow element", () => {
|
||||
const element = API.createElement({
|
||||
type: "arrow",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: { type: ROUNDNESS.PROPORTIONAL_RADIUS },
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
element.__proto__ = { hello: "world" };
|
||||
|
||||
mutateElement(element, {
|
||||
points: [pointFrom<LocalPoint>(1, 2), pointFrom<LocalPoint>(3, 4)],
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
// assert we clone the object's prototype
|
||||
// @ts-ignore
|
||||
expect(copy.__proto__).toEqual({ hello: "world" });
|
||||
expect(copy.hasOwnProperty("hello")).toBe(false);
|
||||
|
||||
expect(copy.points).not.toBe(element.points);
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(copy.seed).not.toBe(element.seed);
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
expect(copy).toEqual({
|
||||
...element,
|
||||
id: copy.id,
|
||||
seed: copy.seed,
|
||||
});
|
||||
});
|
||||
|
||||
it("clones text element", () => {
|
||||
const element = API.createElement({
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "hachure",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roundness: null,
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
text: "hello",
|
||||
fontSize: 20,
|
||||
fontFamily: FONT_FAMILY.Virgil,
|
||||
textAlign: "left",
|
||||
verticalAlign: "top",
|
||||
});
|
||||
|
||||
const copy = duplicateElement(null, new Map(), element);
|
||||
|
||||
assertCloneObjects(element, copy);
|
||||
|
||||
expect(copy).not.toHaveProperty("points");
|
||||
expect(copy).not.toHaveProperty("shape");
|
||||
expect(copy.id).not.toBe(element.id);
|
||||
expect(typeof copy.id).toBe("string");
|
||||
expect(typeof copy.seed).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicating multiple elements", () => {
|
||||
it("duplicateElements should clone bindings", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
boundElements: [
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
{ id: "arrow2", type: "arrow" },
|
||||
{ id: "text1", type: "text" },
|
||||
],
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
containerId: "rectangle1",
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
boundElements: [{ id: "text2", type: "text" }],
|
||||
});
|
||||
|
||||
const text2 = API.createElement({
|
||||
type: "text",
|
||||
id: "text2",
|
||||
containerId: "arrow2",
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, text2] as const;
|
||||
const clonedElements = duplicateElements(origElements);
|
||||
|
||||
// generic id in-equality checks
|
||||
// --------------------------------------------------------------------------
|
||||
expect(origElements.map((e) => e.type)).toEqual(
|
||||
clonedElements.map((e) => e.type),
|
||||
);
|
||||
origElements.forEach((origElement, idx) => {
|
||||
const clonedElement = clonedElements[idx];
|
||||
expect(origElement).toEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.not.stringMatching(clonedElement.id),
|
||||
type: clonedElement.type,
|
||||
}),
|
||||
);
|
||||
if ("containerId" in origElement) {
|
||||
expect(origElement.containerId).not.toBe(
|
||||
(clonedElement as any).containerId,
|
||||
);
|
||||
}
|
||||
if ("endBinding" in origElement) {
|
||||
if (origElement.endBinding) {
|
||||
expect(origElement.endBinding.elementId).not.toBe(
|
||||
(clonedElement as any).endBinding?.elementId,
|
||||
);
|
||||
} else {
|
||||
expect((clonedElement as any).endBinding).toBeNull();
|
||||
}
|
||||
}
|
||||
if ("startBinding" in origElement) {
|
||||
if (origElement.startBinding) {
|
||||
expect(origElement.startBinding.elementId).not.toBe(
|
||||
(clonedElement as any).startBinding?.elementId,
|
||||
);
|
||||
} else {
|
||||
expect((clonedElement as any).startBinding).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
const clonedArrows = clonedElements.filter(
|
||||
(e) => e.type === "arrow",
|
||||
) as ExcalidrawLinearElement[];
|
||||
|
||||
const [clonedRectangle, clonedText1, , clonedArrow2, clonedArrowLabel] =
|
||||
clonedElements as any as typeof origElements;
|
||||
|
||||
expect(clonedText1.containerId).toBe(clonedRectangle.id);
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === clonedText1.id),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: clonedText1.id,
|
||||
type: clonedText1.type,
|
||||
}),
|
||||
);
|
||||
|
||||
clonedArrows.forEach((arrow) => {
|
||||
expect(
|
||||
clonedRectangle.boundElements!.find((e) => e.id === arrow.id),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
id: arrow.id,
|
||||
type: arrow.type,
|
||||
}),
|
||||
);
|
||||
|
||||
if (arrow.endBinding) {
|
||||
expect(arrow.endBinding.elementId).toBe(clonedRectangle.id);
|
||||
}
|
||||
if (arrow.startBinding) {
|
||||
expect(arrow.startBinding.elementId).toBe(clonedRectangle.id);
|
||||
}
|
||||
});
|
||||
|
||||
expect(clonedArrow2.boundElements).toEqual([
|
||||
{ type: "text", id: clonedArrowLabel.id },
|
||||
]);
|
||||
expect(clonedArrowLabel.containerId).toBe(clonedArrow2.id);
|
||||
});
|
||||
|
||||
it("should remove id references of elements that aren't found", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
id: "rectangle1",
|
||||
boundElements: [
|
||||
// should keep
|
||||
{ id: "arrow1", type: "arrow" },
|
||||
// should drop
|
||||
{ id: "arrow-not-exists", type: "arrow" },
|
||||
// should drop
|
||||
{ id: "text-not-exists", type: "text" },
|
||||
],
|
||||
});
|
||||
|
||||
const arrow1 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow1",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const text1 = API.createElement({
|
||||
type: "text",
|
||||
id: "text1",
|
||||
containerId: "rectangle-not-exists",
|
||||
});
|
||||
|
||||
const arrow2 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
startBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
const arrow3 = API.createElement({
|
||||
type: "arrow",
|
||||
id: "arrow2",
|
||||
startBinding: {
|
||||
elementId: "rectangle-not-exists",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
endBinding: {
|
||||
elementId: "rectangle1",
|
||||
focus: 0.2,
|
||||
gap: 7,
|
||||
fixedPoint: [0.5, 1],
|
||||
},
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const origElements = [rectangle1, text1, arrow1, arrow2, arrow3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const [
|
||||
clonedRectangle,
|
||||
clonedText1,
|
||||
clonedArrow1,
|
||||
clonedArrow2,
|
||||
clonedArrow3,
|
||||
] = clonedElements;
|
||||
|
||||
expect(clonedRectangle.boundElements).toEqual([
|
||||
{ id: clonedArrow1.id, type: "arrow" },
|
||||
]);
|
||||
|
||||
expect(clonedText1.containerId).toBe(null);
|
||||
|
||||
expect(clonedArrow2.startBinding).toEqual({
|
||||
...arrow2.startBinding,
|
||||
elementId: clonedRectangle.id,
|
||||
});
|
||||
expect(clonedArrow2.endBinding).toBe(null);
|
||||
|
||||
expect(clonedArrow3.startBinding).toBe(null);
|
||||
expect(clonedArrow3.endBinding).toEqual({
|
||||
...arrow3.endBinding,
|
||||
elementId: clonedRectangle.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe("should duplicate all group ids", () => {
|
||||
it("should regenerate all group ids and keep them consistent across elements", () => {
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
const rectangle2 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g2", "g1"],
|
||||
});
|
||||
const rectangle3 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g2", "g1"],
|
||||
});
|
||||
|
||||
const origElements = [rectangle1, rectangle2, rectangle3] as const;
|
||||
const clonedElements = duplicateElements(
|
||||
origElements,
|
||||
) as any as typeof origElements;
|
||||
const [clonedRectangle1, clonedRectangle2, clonedRectangle3] =
|
||||
clonedElements;
|
||||
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
expect(rectangle2.groupIds[0]).not.toBe(clonedRectangle2.groupIds[0]);
|
||||
expect(rectangle2.groupIds[1]).not.toBe(clonedRectangle2.groupIds[1]);
|
||||
|
||||
expect(clonedRectangle1.groupIds[0]).toBe(clonedRectangle2.groupIds[1]);
|
||||
expect(clonedRectangle2.groupIds[0]).toBe(clonedRectangle3.groupIds[0]);
|
||||
expect(clonedRectangle2.groupIds[1]).toBe(clonedRectangle3.groupIds[1]);
|
||||
});
|
||||
|
||||
it("should keep and regenerate ids of groups even if invalid", () => {
|
||||
// lone element shouldn't be able to be grouped with itself,
|
||||
// but hard to check against in a performant way so we ignore it
|
||||
const rectangle1 = API.createElement({
|
||||
type: "rectangle",
|
||||
groupIds: ["g1"],
|
||||
});
|
||||
|
||||
const [clonedRectangle1] = duplicateElements([rectangle1]);
|
||||
|
||||
expect(typeof clonedRectangle1.groupIds[0]).toBe("string");
|
||||
expect(rectangle1.groupIds[0]).not.toBe(clonedRectangle1.groupIds[0]);
|
||||
});
|
||||
});
|
||||
});
|
69
packages/element/tests/sizeHelpers.test.ts
Normal file
69
packages/element/tests/sizeHelpers.test.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { vi } from "vitest";
|
||||
|
||||
import * as constants from "@excalidraw/common";
|
||||
|
||||
import { getPerfectElementSize } from "../src/sizeHelpers";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
// Needed so that we can mock the value of constants which is done in
|
||||
// below tests. In Jest this wasn't needed as global override was possible
|
||||
// but vite doesn't allow that hence we need to mock
|
||||
vi.mock(
|
||||
"../constants.ts",
|
||||
//@ts-ignore
|
||||
async (importOriginal) => {
|
||||
const module: any = await importOriginal();
|
||||
return { ...module };
|
||||
},
|
||||
);
|
||||
describe("getPerfectElementSize", () => {
|
||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 10, 140);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
it("should return width:0 if `elementType` is arrow and locked angle is 90 deg (Math.PI/2)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 10, 100);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
|
||||
it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
|
||||
(constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
});
|
||||
});
|
||||
});
|
404
packages/element/tests/sortElements.test.ts
Normal file
404
packages/element/tests/sortElements.test.ts
Normal file
|
@ -0,0 +1,404 @@
|
|||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { mutateElement } from "../src/mutateElement";
|
||||
import { normalizeElementOrder } from "../src/sortElements";
|
||||
|
||||
import type { ExcalidrawElement } from "../src/types";
|
||||
|
||||
const assertOrder = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
expectedOrder: string[],
|
||||
) => {
|
||||
const actualOrder = elements.map((element) => element.id);
|
||||
expect(actualOrder).toEqual(expectedOrder);
|
||||
};
|
||||
|
||||
describe("normalizeElementsOrder", () => {
|
||||
it("sort bound-text elements", () => {
|
||||
const container = API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
});
|
||||
const boundText = API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
});
|
||||
const otherElement = API.createElement({
|
||||
id: "otherElement",
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
const otherElement2 = API.createElement({
|
||||
id: "otherElement2",
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
assertOrder(normalizeElementOrder([container, boundText]), [
|
||||
"container",
|
||||
"boundText",
|
||||
]);
|
||||
assertOrder(normalizeElementOrder([boundText, container]), [
|
||||
"container",
|
||||
"boundText",
|
||||
]);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
boundText,
|
||||
container,
|
||||
otherElement,
|
||||
otherElement2,
|
||||
]),
|
||||
["container", "boundText", "otherElement", "otherElement2"],
|
||||
);
|
||||
assertOrder(normalizeElementOrder([container, otherElement, boundText]), [
|
||||
"container",
|
||||
"boundText",
|
||||
"otherElement",
|
||||
]);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
container,
|
||||
otherElement,
|
||||
otherElement2,
|
||||
boundText,
|
||||
]),
|
||||
["container", "boundText", "otherElement", "otherElement2"],
|
||||
);
|
||||
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
boundText,
|
||||
otherElement,
|
||||
container,
|
||||
otherElement2,
|
||||
]),
|
||||
["otherElement", "container", "boundText", "otherElement2"],
|
||||
);
|
||||
|
||||
// noop
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
otherElement,
|
||||
container,
|
||||
boundText,
|
||||
otherElement2,
|
||||
]),
|
||||
["otherElement", "container", "boundText", "otherElement2"],
|
||||
);
|
||||
|
||||
// text has existing containerId, but container doesn't list is
|
||||
// as a boundElement
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
containerId: "container",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
}),
|
||||
]),
|
||||
["boundText", "container"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
containerId: "container",
|
||||
}),
|
||||
]),
|
||||
["boundText"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
}),
|
||||
]),
|
||||
["container"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
boundElements: [{ id: "x", type: "text" }],
|
||||
}),
|
||||
]),
|
||||
["container"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "arrow",
|
||||
type: "arrow",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
boundElements: [{ id: "arrow", type: "arrow" }],
|
||||
}),
|
||||
]),
|
||||
["arrow", "container"],
|
||||
);
|
||||
});
|
||||
|
||||
it("normalize group order", () => {
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "A_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect2",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect3",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect4",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect5",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect6",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect7",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
]),
|
||||
["A_rect1", "A_rect4", "A_rect5", "A_rect7", "rect2", "rect3", "rect6"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "A_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect2",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "B_rect3",
|
||||
type: "rectangle",
|
||||
groupIds: ["B"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect4",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "B_rect5",
|
||||
type: "rectangle",
|
||||
groupIds: ["B"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect6",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect7",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
]),
|
||||
["A_rect1", "A_rect4", "A_rect7", "rect2", "B_rect3", "B_rect5", "rect6"],
|
||||
);
|
||||
// nested groups
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "A_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "BA_rect2",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
]),
|
||||
["A_rect1", "BA_rect2"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "BA_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect2",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
]),
|
||||
["BA_rect1", "A_rect2"],
|
||||
);
|
||||
assertOrder(
|
||||
normalizeElementOrder([
|
||||
API.createElement({
|
||||
id: "BA_rect1",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect2",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "CBA_rect3",
|
||||
type: "rectangle",
|
||||
groupIds: ["C", "B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect4",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "A_rect5",
|
||||
type: "rectangle",
|
||||
groupIds: ["A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "BA_rect5",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "BA_rect6",
|
||||
type: "rectangle",
|
||||
groupIds: ["B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "CBA_rect7",
|
||||
type: "rectangle",
|
||||
groupIds: ["C", "B", "A"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "X_rect8",
|
||||
type: "rectangle",
|
||||
groupIds: ["X"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "rect9",
|
||||
type: "rectangle",
|
||||
}),
|
||||
API.createElement({
|
||||
id: "YX_rect10",
|
||||
type: "rectangle",
|
||||
groupIds: ["Y", "X"],
|
||||
}),
|
||||
API.createElement({
|
||||
id: "X_rect11",
|
||||
type: "rectangle",
|
||||
groupIds: ["X"],
|
||||
}),
|
||||
]),
|
||||
[
|
||||
"BA_rect1",
|
||||
"BA_rect5",
|
||||
"BA_rect6",
|
||||
"A_rect2",
|
||||
"A_rect5",
|
||||
"CBA_rect3",
|
||||
"CBA_rect7",
|
||||
"rect4",
|
||||
"X_rect8",
|
||||
"X_rect11",
|
||||
"YX_rect10",
|
||||
"rect9",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
// TODO
|
||||
it.skip("normalize boundElements array", () => {
|
||||
const container = API.createElement({
|
||||
id: "container",
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
});
|
||||
const boundText = API.createElement({
|
||||
id: "boundText",
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
});
|
||||
|
||||
mutateElement(container, {
|
||||
boundElements: [
|
||||
{ type: "text", id: boundText.id },
|
||||
{ type: "text", id: "xxx" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalizeElementOrder([container, boundText])).toEqual([
|
||||
expect.objectContaining({
|
||||
id: container.id,
|
||||
}),
|
||||
expect.objectContaining({ id: boundText.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
// should take around <100ms for 10K iterations (@dwelle's PC 22-05-25)
|
||||
it.skip("normalizeElementsOrder() perf", () => {
|
||||
const makeElements = (iterations: number) => {
|
||||
const elements: ExcalidrawElement[] = [];
|
||||
while (iterations--) {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
groupIds: ["B", "A"],
|
||||
});
|
||||
const boundText = API.createElement({
|
||||
type: "text",
|
||||
containerId: container.id,
|
||||
groupIds: ["A"],
|
||||
});
|
||||
const otherElement = API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [],
|
||||
groupIds: ["C", "A"],
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: boundText.id }],
|
||||
});
|
||||
|
||||
elements.push(boundText, otherElement, container);
|
||||
}
|
||||
return elements;
|
||||
};
|
||||
|
||||
const elements = makeElements(10000);
|
||||
const t0 = Date.now();
|
||||
normalizeElementOrder(elements);
|
||||
console.info(`${Date.now() - t0}ms`);
|
||||
});
|
||||
});
|
209
packages/element/tests/textElement.test.ts
Normal file
209
packages/element/tests/textElement.test.ts
Normal file
|
@ -0,0 +1,209 @@
|
|||
import { getLineHeight } from "@excalidraw/excalidraw/fonts/FontMetadata";
|
||||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
a} from "../src/textElement";
|
||||
import { detectLineHeight, getLineHeightInPx } from "../src/textMeasurements";
|
||||
|
||||
import type { ExcalidrawTextElementWithContainer } from "../src/types";
|
||||
|
||||
describe("Test measureText", () => {
|
||||
describe("Test getContainerCoords", () => {
|
||||
const params = { width: 200, height: 100, x: 10, y: 20 };
|
||||
|
||||
it("should compute coords correctly when ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 44.2893218813452455,
|
||||
y: 39.64466094067262,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 15,
|
||||
y: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("should compute coords correctly when diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(getContainerCoords(element)).toEqual({
|
||||
x: 65,
|
||||
y: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test computeContainerDimensionForBoundText", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should compute container height correctly for rectangle", () => {
|
||||
const element = API.createElement({
|
||||
type: "rectangle",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
160,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for ellipse", () => {
|
||||
const element = API.createElement({
|
||||
type: "ellipse",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
226,
|
||||
);
|
||||
});
|
||||
|
||||
it("should compute container height correctly for diamond", () => {
|
||||
const element = API.createElement({
|
||||
type: "diamond",
|
||||
...params,
|
||||
});
|
||||
expect(computeContainerDimensionForBoundText(150, element.type)).toEqual(
|
||||
320,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
};
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxWidth(container, null)).toBe(79);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
id: '"container-id',
|
||||
};
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: params.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
height: 70,
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
});
|
||||
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||
boundTextElement.height,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const textElement = API.createElement({
|
||||
type: "text",
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
height: 175,
|
||||
});
|
||||
|
||||
describe("Test detectLineHeight", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(detectLineHeight(textElement)).toBe(1.25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getLineHeightInPx", () => {
|
||||
it("should return correct line height", () => {
|
||||
expect(
|
||||
getLineHeightInPx(textElement.fontSize, textElement.lineHeight),
|
||||
).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getLineHeight()).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return line height using default font family for unknown font", () => {
|
||||
const UNKNOWN_FONT = 5;
|
||||
expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return correct line height", () => {
|
||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
634
packages/element/tests/textWrapping.test.ts
Normal file
634
packages/element/tests/textWrapping.test.ts
Normal file
|
@ -0,0 +1,634 @@
|
|||
import { wrapText, parseTokens } from "../src/textWrapping";
|
||||
|
||||
import type { FontString } from "../src/types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
// font is irrelevant as jsdom does not support FontFace API
|
||||
// `measureText` width is mocked to return `text.length` by `jest-canvas-mock`
|
||||
// https://github.com/hustcc/jest-canvas-mock/blob/master/src/classes/TextMetrics.js
|
||||
const font = "10px Cascadia, Segoe UI Emoji" as FontString;
|
||||
|
||||
it("should wrap the text correctly when word length is exactly equal to max width", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
// Length of "Excalidraw" is 100 and exacty equal to max width
|
||||
const res = wrapText(text, font, 100);
|
||||
expect(res).toEqual(`Hello\nExcalidraw`);
|
||||
});
|
||||
|
||||
it("should return the text as is if max width is invalid", () => {
|
||||
const text = "Hello Excalidraw";
|
||||
expect(wrapText(text, font, NaN)).toEqual(text);
|
||||
expect(wrapText(text, font, -1)).toEqual(text);
|
||||
expect(wrapText(text, font, Infinity)).toEqual(text);
|
||||
});
|
||||
|
||||
it("should show the text correctly when max width reached", () => {
|
||||
const text = "Hello😀";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("H\ne\nl\nl\no\n😀");
|
||||
});
|
||||
|
||||
it("should not wrap number when wrapping line", () => {
|
||||
const text = "don't wrap this number 99,100.99";
|
||||
const maxWidth = 300;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("don't wrap this number\n99,100.99");
|
||||
});
|
||||
|
||||
it("should trim all trailing whitespaces", () => {
|
||||
const text = "Hello ";
|
||||
const maxWidth = 50;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello");
|
||||
});
|
||||
|
||||
it("should trim all but one trailing whitespaces", () => {
|
||||
const text = "Hello ";
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello ");
|
||||
});
|
||||
|
||||
it("should keep preceding whitespaces and trim all trailing whitespaces", () => {
|
||||
const text = " Hello World";
|
||||
const maxWidth = 90;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" Hello\nWorld");
|
||||
});
|
||||
|
||||
it("should keep some preceding whitespaces, trim trailing whitespaces, but kep those that fit in the trailing line", () => {
|
||||
const text = " Hello World ";
|
||||
const maxWidth = 90;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" Hello\nWorld ");
|
||||
});
|
||||
|
||||
it("should trim keep those whitespace that fit in the trailing line", () => {
|
||||
const text = "Hello Wo rl d ";
|
||||
const maxWidth = 100;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello Wo\nrl d ");
|
||||
});
|
||||
|
||||
it("should support multiple (multi-codepoint) emojis", () => {
|
||||
const text = "😀🗺🔥👩🏽🦰👨👩👧👦🇨🇿";
|
||||
const maxWidth = 1;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("😀\n🗺\n🔥\n👩🏽🦰\n👨👩👧👦\n🇨🇿");
|
||||
});
|
||||
|
||||
it("should wrap the text correctly when text contains hyphen", () => {
|
||||
let text =
|
||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||
const res = wrapText(text, font, 110);
|
||||
expect(res).toBe(
|
||||
`Wikipedia\nis hosted\nby\nWikimedia-\nFoundation,\na non-\nprofit\norganizatio\nn that also\nhosts a\nrange-of\nother\nprojects`,
|
||||
);
|
||||
|
||||
text = "Hello thereusing-now";
|
||||
expect(wrapText(text, font, 100)).toEqual("Hello\nthereusing\n-now");
|
||||
});
|
||||
|
||||
it("should support wrapping nested lists", () => {
|
||||
const text = `\tA) one tab\t\t- two tabs - 8 spaces`;
|
||||
|
||||
const maxWidth = 100;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(`\tA) one\ntab\t\t- two\ntabs\n- 8 spaces`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
|
||||
});
|
||||
|
||||
describe("When text is CJK", () => {
|
||||
it("should break each CJK character when width is very small", () => {
|
||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
||||
const maxWidth = 10;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(
|
||||
"안\n녕\n하\n세\n요\nこ\nん\nに\nち\nは\n世\n界\nコ\nン\nニ\nチ\nハ\n你\n好",
|
||||
);
|
||||
});
|
||||
|
||||
it("should break CJK text into longer segments when width is larger", () => {
|
||||
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
|
||||
const text = "안녕하세요こんにちは世界コンニチハ你好";
|
||||
const maxWidth = 30;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
|
||||
// measureText is mocked, so it's not precisely what would happen in prod
|
||||
expect(res).toBe("안녕하\n세요こ\nんにち\nは世界\nコンニ\nチハ你\n好");
|
||||
});
|
||||
|
||||
it("should handle a combination of CJK, latin, emojis and whitespaces", () => {
|
||||
const text = `a醫 醫 bb 你好 world-i-😀🗺🔥`;
|
||||
|
||||
const maxWidth = 150;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(`a醫 醫 bb 你\n好 world-i-😀🗺\n🔥`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`a醫 醫\nbb 你\n好\nworld\n-i-😀\n🗺🔥`);
|
||||
|
||||
const maxWidth3 = 30;
|
||||
const res3 = wrapText(text, font, maxWidth3);
|
||||
expect(res3).toBe(`a醫\n醫\nbb\n你好\nwor\nld-\ni-\n😀\n🗺\n🔥`);
|
||||
});
|
||||
|
||||
it("should break before and after a regular CJK character", () => {
|
||||
const text = "HelloたWorld";
|
||||
const maxWidth1 = 50;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe("Hello\nた\nWorld");
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe("Helloた\nWorld");
|
||||
});
|
||||
|
||||
it("should break before and after certain CJK symbols", () => {
|
||||
const text = "こんにちは〃世界";
|
||||
const maxWidth1 = 50;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe("こんにちは\n〃世界");
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe("こんにちは〃\n世界");
|
||||
});
|
||||
|
||||
it("should break after, not before for certain CJK pairs", () => {
|
||||
const text = "Hello た。";
|
||||
const maxWidth = 70;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello\nた。");
|
||||
});
|
||||
|
||||
it("should break before, not after for certain CJK pairs", () => {
|
||||
const text = "Hello「たWorld」";
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hello\n「た\nWorld」");
|
||||
});
|
||||
|
||||
it("should break after, not before for certain CJK character pairs", () => {
|
||||
const text = "「Helloた」World";
|
||||
const maxWidth = 70;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("「Hello\nた」World");
|
||||
});
|
||||
|
||||
it("should break Chinese sentences", () => {
|
||||
const text = `中国你好!这是一个测试。
|
||||
我们来看看:人民币¥1234「很贵」
|
||||
(括号)、逗号,句号。空格 换行 全角符号…—`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`中国你好!这是一\n个测试。
|
||||
我们来看看:人民\n币¥1234「很\n贵」
|
||||
(括号)、逗号,\n句号。空格 换行\n全角符号…—`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`中国你好!\n这是一个测\n试。
|
||||
我们来看\n看:人民币\n¥1234\n「很贵」
|
||||
(括号)、\n逗号,句\n号。空格\n换行 全角\n符号…—`);
|
||||
});
|
||||
|
||||
it("should break Japanese sentences", () => {
|
||||
const text = `日本こんにちは!これはテストです。
|
||||
見てみましょう:円¥1234「高い」
|
||||
(括弧)、読点、句点。
|
||||
空白 改行 全角記号…ー`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`日本こんにちは!\nこれはテストで\nす。
|
||||
見てみましょ\nう:円¥1234\n「高い」
|
||||
(括弧)、読\n点、句点。
|
||||
空白 改行\n全角記号…ー`);
|
||||
|
||||
const maxWidth2 = 50;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`日本こんに\nちは!これ\nはテストで\nす。
|
||||
見てみ\nましょう:\n円\n¥1234\n「高い」
|
||||
(括\n弧)、読\n点、句点。
|
||||
空白\n改行 全角\n記号…ー`);
|
||||
});
|
||||
|
||||
it("should break Korean sentences", () => {
|
||||
const text = `한국 안녕하세요! 이것은 테스트입니다.
|
||||
우리 보자: 원화₩1234「비싸다」
|
||||
(괄호), 쉼표, 마침표.
|
||||
공백 줄바꿈 전각기호…—`;
|
||||
|
||||
const maxWidth1 = 80;
|
||||
const res1 = wrapText(text, font, maxWidth1);
|
||||
expect(res1).toBe(`한국 안녕하세\n요! 이것은 테\n스트입니다.
|
||||
우리 보자: 원\n화₩1234「비\n싸다」
|
||||
(괄호), 쉼\n표, 마침표.
|
||||
공백 줄바꿈 전\n각기호…—`);
|
||||
|
||||
const maxWidth2 = 60;
|
||||
const res2 = wrapText(text, font, maxWidth2);
|
||||
expect(res2).toBe(`한국 안녕하\n세요! 이것\n은 테스트입\n니다.
|
||||
우리 보자:\n원화\n₩1234\n「비싸다」
|
||||
(괄호),\n쉼표, 마침\n표.
|
||||
공백 줄바꿈\n전각기호…—`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contains leading whitespaces", () => {
|
||||
const text = " \t Hello world";
|
||||
|
||||
it("should preserve leading whitespaces", () => {
|
||||
const maxWidth = 120;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(" \t Hello\nworld");
|
||||
});
|
||||
|
||||
it("should break and collapse leading whitespaces when line breaks", () => {
|
||||
const maxWidth = 60;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("\nHello\nworld");
|
||||
});
|
||||
|
||||
it("should break and collapse leading whitespaces whe words break", () => {
|
||||
const maxWidth = 30;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("\nHel\nlo\nwor\nld");
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contains trailing whitespaces", () => {
|
||||
it("shouldn't add new lines for trailing spaces", () => {
|
||||
const text = "Hello whats up ";
|
||||
const maxWidth = 190;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe(text);
|
||||
});
|
||||
|
||||
it("should ignore trailing whitespaces when line breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 400;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrosesquippedaliophobia\n??????");
|
||||
});
|
||||
|
||||
it("should not ignore trailing whitespaces when word breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 300;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrosesquippedalio\nphobia ??????");
|
||||
});
|
||||
|
||||
it("should ignore trailing whitespaces when word breaks and line breaks", () => {
|
||||
const text = "Hippopotomonstrosesquippedaliophobia ??????";
|
||||
const maxWidth = 180;
|
||||
const res = wrapText(text, font, maxWidth);
|
||||
expect(res).toBe("Hippopotomonstrose\nsquippedaliophobia\n??????");
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text doesn't contain new lines", () => {
|
||||
const text = "Hello whats up";
|
||||
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 70,
|
||||
res: `Hello\nwhats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 15,
|
||||
res: `H\ne\nl\nl\no\nw\nh\na\nt\ns\nu\np`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
|
||||
width: 130,
|
||||
res: `Hello whats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "fit the container",
|
||||
|
||||
width: 240,
|
||||
res: "Hello whats up",
|
||||
},
|
||||
{
|
||||
desc: "push the word if its equal to max width",
|
||||
width: 50,
|
||||
res: `Hello\nwhats\nup`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text contain new lines", () => {
|
||||
const text = `Hello\n whats up`;
|
||||
[
|
||||
{
|
||||
desc: "break all words when width of each word is less than container width",
|
||||
width: 70,
|
||||
res: `Hello\n whats\nup`,
|
||||
},
|
||||
{
|
||||
desc: "break all characters when width of each character is less than container width",
|
||||
width: 15,
|
||||
res: `H\ne\nl\nl\no\n\nw\nh\na\nt\ns\nu\np`,
|
||||
},
|
||||
{
|
||||
desc: "break words as per the width",
|
||||
width: 140,
|
||||
res: `Hello\n whats up`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should respect new lines and ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("When text is long", () => {
|
||||
const text = `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg break it now`;
|
||||
[
|
||||
{
|
||||
desc: "fit characters of long string as per container width",
|
||||
width: 160,
|
||||
res: `hellolongtextthi\nsiswhatsupwithyo\nuIamtypingggggan\ndtypinggg break\nit now`,
|
||||
},
|
||||
{
|
||||
desc: "fit characters of long string as per container width and break words as per the width",
|
||||
|
||||
width: 120,
|
||||
res: `hellolongtex\ntthisiswhats\nupwithyouIam\ntypingggggan\ndtypinggg\nbreak it now`,
|
||||
},
|
||||
{
|
||||
desc: "fit the long text when container width is greater than text length and move the rest to next line",
|
||||
|
||||
width: 590,
|
||||
res: `hellolongtextthisiswhatsupwithyouIamtypingggggandtypinggg\nbreak it now`,
|
||||
},
|
||||
].forEach((data) => {
|
||||
it(`should ${data.desc}`, () => {
|
||||
const res = wrapText(text, font, data.width);
|
||||
expect(res).toEqual(data.res);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test parseTokens", () => {
|
||||
it("should tokenize latin", () => {
|
||||
let text = "Excalidraw is a virtual collaborative whiteboard";
|
||||
|
||||
expect(parseTokens(text)).toEqual([
|
||||
"Excalidraw",
|
||||
" ",
|
||||
"is",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"virtual",
|
||||
" ",
|
||||
"collaborative",
|
||||
" ",
|
||||
"whiteboard",
|
||||
]);
|
||||
|
||||
text =
|
||||
"Wikipedia is hosted by Wikimedia- Foundation, a non-profit organization that also hosts a range-of other projects";
|
||||
expect(parseTokens(text)).toEqual([
|
||||
"Wikipedia",
|
||||
" ",
|
||||
"is",
|
||||
" ",
|
||||
"hosted",
|
||||
" ",
|
||||
"by",
|
||||
" ",
|
||||
"Wikimedia-",
|
||||
" ",
|
||||
"Foundation,",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"non-",
|
||||
"profit",
|
||||
" ",
|
||||
"organization",
|
||||
" ",
|
||||
"that",
|
||||
" ",
|
||||
"also",
|
||||
" ",
|
||||
"hosts",
|
||||
" ",
|
||||
"a",
|
||||
" ",
|
||||
"range-",
|
||||
"of",
|
||||
" ",
|
||||
"other",
|
||||
" ",
|
||||
"projects",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should not tokenize number", () => {
|
||||
const text = "99,100.99";
|
||||
const tokens = parseTokens(text);
|
||||
expect(tokens).toEqual(["99,100.99"]);
|
||||
});
|
||||
|
||||
it("should tokenize joined emojis", () => {
|
||||
const text = `😬🌍🗺🔥☂️👩🏽🦰👨👩👧👦👩🏾🔬🏳️🌈🧔♀️🧑🤝🧑🙅🏽♂️✅0️⃣🇨🇿🦅`;
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
expect(tokens).toEqual([
|
||||
"😬",
|
||||
"🌍",
|
||||
"🗺",
|
||||
"🔥",
|
||||
"☂️",
|
||||
"👩🏽🦰",
|
||||
"👨👩👧👦",
|
||||
"👩🏾🔬",
|
||||
"🏳️🌈",
|
||||
"🧔♀️",
|
||||
"🧑🤝🧑",
|
||||
"🙅🏽♂️",
|
||||
"✅",
|
||||
"0️⃣",
|
||||
"🇨🇿",
|
||||
"🦅",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should tokenize emojis mixed with mixed text", () => {
|
||||
const text = `😬a🌍b🗺c🔥d☂️《👩🏽🦰》👨👩👧👦德👩🏾🔬こ🏳️🌈안🧔♀️g🧑🤝🧑h🙅🏽♂️e✅f0️⃣g🇨🇿10🦅#hash`;
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
expect(tokens).toEqual([
|
||||
"😬",
|
||||
"a",
|
||||
"🌍",
|
||||
"b",
|
||||
"🗺",
|
||||
"c",
|
||||
"🔥",
|
||||
"d",
|
||||
"☂️",
|
||||
"《",
|
||||
"👩🏽🦰",
|
||||
"》",
|
||||
"👨👩👧👦",
|
||||
"德",
|
||||
"👩🏾🔬",
|
||||
"こ",
|
||||
"🏳️🌈",
|
||||
"안",
|
||||
"🧔♀️",
|
||||
"g",
|
||||
"🧑🤝🧑",
|
||||
"h",
|
||||
"🙅🏽♂️",
|
||||
"e",
|
||||
"✅",
|
||||
"f0️⃣g", // bummer, but ok, as we traded kecaps not breaking (less common) for hash and numbers not breaking (more common)
|
||||
"🇨🇿",
|
||||
"10", // nice! do not break the number, as it's by default matched by \p{Emoji}
|
||||
"🦅",
|
||||
"#hash", // nice! do not break the hash, as it's by default matched by \p{Emoji}
|
||||
]);
|
||||
});
|
||||
|
||||
it("should tokenize decomposed chars into their composed variants", () => {
|
||||
// each input character is in a decomposed form
|
||||
const text = "čでäぴέ다й한";
|
||||
expect(text.normalize("NFC").length).toEqual(8);
|
||||
expect(text).toEqual(text.normalize("NFD"));
|
||||
|
||||
const tokens = parseTokens(text);
|
||||
expect(tokens.length).toEqual(8);
|
||||
expect(tokens).toEqual(["č", "で", "ä", "ぴ", "έ", "다", "й", "한"]);
|
||||
});
|
||||
|
||||
it("should tokenize artificial CJK", () => {
|
||||
const text = `《道德經》醫-醫こんにちは世界!안녕하세요세계;요』,다.다...원/달(((다)))[[1]]〚({((한))>)〛(「た」)た…[Hello] \t World?ニューヨーク・¥3700.55す。090-1234-5678¥1,000〜$5,000「素晴らしい!」〔重要〕#1:Taro君30%は、(たなばた)〰¥110±¥570で20℃〜9:30〜10:00【一番】`;
|
||||
// [
|
||||
// '《道', '德', '經》', '醫-',
|
||||
// '醫', 'こ', 'ん', 'に',
|
||||
// 'ち', 'は', '世', '界!',
|
||||
// '안', '녕', '하', '세',
|
||||
// '요', '세', '계;', '요』,',
|
||||
// '다.', '다...', '원/', '달',
|
||||
// '(((다)))', '[[1]]', '〚({((한))>)〛', '(「た」)',
|
||||
// 'た…', '[Hello]', ' ', '\t',
|
||||
// ' ', 'World?', 'ニ', 'ュ',
|
||||
// 'ー', 'ヨ', 'ー', 'ク・',
|
||||
// '¥3700.55', 'す。', '090-', '1234-',
|
||||
// '5678', '¥1,000〜', '$5,000', '「素',
|
||||
// '晴', 'ら', 'し', 'い!」',
|
||||
// '〔重', '要〕', '#', '1:',
|
||||
// 'Taro', '君', '30%', 'は、',
|
||||
// '(た', 'な', 'ば', 'た)',
|
||||
// '〰', '¥110±', '¥570', 'で',
|
||||
// '20℃〜', '9:30〜', '10:00', '【一',
|
||||
// '番】'
|
||||
// ]
|
||||
const tokens = parseTokens(text);
|
||||
|
||||
// Latin
|
||||
expect(tokens).toContain("[[1]]");
|
||||
expect(tokens).toContain("[Hello]");
|
||||
expect(tokens).toContain("World?");
|
||||
expect(tokens).toContain("Taro");
|
||||
|
||||
// Chinese
|
||||
expect(tokens).toContain("《道");
|
||||
expect(tokens).toContain("德");
|
||||
expect(tokens).toContain("經》");
|
||||
expect(tokens).toContain("醫-");
|
||||
expect(tokens).toContain("醫");
|
||||
|
||||
// Japanese
|
||||
expect(tokens).toContain("こ");
|
||||
expect(tokens).toContain("ん");
|
||||
expect(tokens).toContain("に");
|
||||
expect(tokens).toContain("ち");
|
||||
expect(tokens).toContain("は");
|
||||
expect(tokens).toContain("世");
|
||||
expect(tokens).toContain("ク・");
|
||||
expect(tokens).toContain("界!");
|
||||
expect(tokens).toContain("た…");
|
||||
expect(tokens).toContain("す。");
|
||||
expect(tokens).toContain("ュ");
|
||||
expect(tokens).toContain("「素");
|
||||
expect(tokens).toContain("晴");
|
||||
expect(tokens).toContain("ら");
|
||||
expect(tokens).toContain("し");
|
||||
expect(tokens).toContain("い!」");
|
||||
expect(tokens).toContain("君");
|
||||
expect(tokens).toContain("は、");
|
||||
expect(tokens).toContain("(た");
|
||||
expect(tokens).toContain("な");
|
||||
expect(tokens).toContain("ば");
|
||||
expect(tokens).toContain("た)");
|
||||
expect(tokens).toContain("で");
|
||||
expect(tokens).toContain("【一");
|
||||
expect(tokens).toContain("番】");
|
||||
|
||||
// Check for Korean
|
||||
expect(tokens).toContain("안");
|
||||
expect(tokens).toContain("녕");
|
||||
expect(tokens).toContain("하");
|
||||
expect(tokens).toContain("세");
|
||||
expect(tokens).toContain("요");
|
||||
expect(tokens).toContain("세");
|
||||
expect(tokens).toContain("계;");
|
||||
expect(tokens).toContain("요』,");
|
||||
expect(tokens).toContain("다.");
|
||||
expect(tokens).toContain("다...");
|
||||
expect(tokens).toContain("원/");
|
||||
expect(tokens).toContain("달");
|
||||
expect(tokens).toContain("(((다)))");
|
||||
expect(tokens).toContain("〚({((한))>)〛");
|
||||
expect(tokens).toContain("(「た」)");
|
||||
|
||||
// Numbers and units
|
||||
expect(tokens).toContain("¥3700.55");
|
||||
expect(tokens).toContain("090-");
|
||||
expect(tokens).toContain("1234-");
|
||||
expect(tokens).toContain("5678");
|
||||
expect(tokens).toContain("¥1,000〜");
|
||||
expect(tokens).toContain("$5,000");
|
||||
expect(tokens).toContain("1:");
|
||||
expect(tokens).toContain("30%");
|
||||
expect(tokens).toContain("¥110±");
|
||||
expect(tokens).toContain("20℃〜");
|
||||
expect(tokens).toContain("9:30〜");
|
||||
expect(tokens).toContain("10:00");
|
||||
|
||||
// Punctuation and symbols
|
||||
expect(tokens).toContain(" ");
|
||||
expect(tokens).toContain("\t");
|
||||
expect(tokens).toContain(" ");
|
||||
expect(tokens).toContain("ニ");
|
||||
expect(tokens).toContain("ー");
|
||||
expect(tokens).toContain("ヨ");
|
||||
expect(tokens).toContain("〰");
|
||||
expect(tokens).toContain("#");
|
||||
});
|
||||
});
|
||||
});
|
67
packages/element/tests/typeChecks.test.ts
Normal file
67
packages/element/tests/typeChecks.test.ts
Normal file
|
@ -0,0 +1,67 @@
|
|||
import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
|
||||
import { hasBoundTextElement } from "../src/typeChecks";
|
||||
|
||||
describe("Test TypeChecks", () => {
|
||||
describe("Test hasBoundTextElement", () => {
|
||||
it("should return true for text bindable containers with bound text", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "ellipse",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "arrow",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false for text bindable containers without bound text", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
boundElements: [{ type: "arrow", id: "arrow-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should return false for non text bindable containers", () => {
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "freedraw",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "image",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue