You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
519 lines
14 KiB
TypeScript
519 lines
14 KiB
TypeScript
import { vi } from "vitest";
|
|
import ReactDOM from "react-dom";
|
|
import { render, waitFor, GlobalTestState } from "./test-utils";
|
|
import { Pointer, Keyboard } from "./helpers/ui";
|
|
import { Excalidraw } from "../index";
|
|
import { KEYS } from "../keys";
|
|
import {
|
|
getDefaultLineHeight,
|
|
getLineHeightInPx,
|
|
} from "../element/textElement";
|
|
import { getElementBounds } from "../element";
|
|
import type { NormalizedZoomValue } from "../types";
|
|
import { API } from "./helpers/api";
|
|
import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard";
|
|
import { arrayToMap } from "../utils";
|
|
import { mockMermaidToExcalidraw } from "./helpers/mocks";
|
|
|
|
const { h } = window;
|
|
|
|
const mouse = new Pointer("mouse");
|
|
|
|
vi.mock("../keys.ts", async (importOriginal) => {
|
|
const module: any = await importOriginal();
|
|
return {
|
|
__esmodule: true,
|
|
...module,
|
|
isDarwin: false,
|
|
KEYS: {
|
|
...module.KEYS,
|
|
CTRL_OR_CMD: "ctrlKey",
|
|
},
|
|
};
|
|
});
|
|
|
|
const sendPasteEvent = (text: string) => {
|
|
const clipboardEvent = createPasteEvent({
|
|
types: {
|
|
"text/plain": text,
|
|
},
|
|
});
|
|
document.dispatchEvent(clipboardEvent);
|
|
};
|
|
|
|
const pasteWithCtrlCmdShiftV = (text: string) => {
|
|
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
|
//triggering keydown with an empty clipboard
|
|
Keyboard.keyPress(KEYS.V);
|
|
//triggering paste event with faked clipboard
|
|
sendPasteEvent(text);
|
|
});
|
|
};
|
|
|
|
const pasteWithCtrlCmdV = (text: string) => {
|
|
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
|
//triggering keydown with an empty clipboard
|
|
Keyboard.keyPress(KEYS.V);
|
|
//triggering paste event with faked clipboard
|
|
sendPasteEvent(text);
|
|
});
|
|
};
|
|
|
|
const sleep = (ms: number) => {
|
|
return new Promise((resolve) => setTimeout(() => resolve(null), ms));
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
|
|
|
localStorage.clear();
|
|
|
|
mouse.reset();
|
|
|
|
await render(
|
|
<Excalidraw
|
|
autoFocus={true}
|
|
handleKeyboardGlobally={true}
|
|
initialData={{ appState: { zoom: { value: 1 as NormalizedZoomValue } } }}
|
|
/>,
|
|
);
|
|
Object.assign(document, {
|
|
elementFromPoint: () => GlobalTestState.canvas,
|
|
});
|
|
});
|
|
|
|
describe("general paste behavior", () => {
|
|
it("should randomize seed on paste", async () => {
|
|
const rectangle = API.createElement({ type: "rectangle" });
|
|
const clipboardJSON = await serializeAsClipboardJSON({
|
|
elements: [rectangle],
|
|
files: null,
|
|
});
|
|
pasteWithCtrlCmdV(clipboardJSON);
|
|
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toBe(1);
|
|
expect(h.elements[0].seed).not.toBe(rectangle.seed);
|
|
});
|
|
});
|
|
|
|
it("should retain seed on shift-paste", async () => {
|
|
const rectangle = API.createElement({ type: "rectangle" });
|
|
const clipboardJSON = await serializeAsClipboardJSON({
|
|
elements: [rectangle],
|
|
files: null,
|
|
});
|
|
|
|
// assert we don't randomize seed on shift-paste
|
|
pasteWithCtrlCmdShiftV(clipboardJSON);
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toBe(1);
|
|
expect(h.elements[0].seed).toBe(rectangle.seed);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("paste text as single lines", () => {
|
|
it("should create an element for each line when copying with Ctrl/Cmd+V", async () => {
|
|
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
|
pasteWithCtrlCmdV(text);
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toEqual(text.split("\n").length);
|
|
});
|
|
});
|
|
|
|
it("should ignore empty lines when creating an element for each line", async () => {
|
|
const text = "\n\nsajgfakfn\n\n\naaksfnknas\n\nakefnkasf\n\n\n";
|
|
pasteWithCtrlCmdV(text);
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toEqual(3);
|
|
});
|
|
});
|
|
|
|
it("should not create any element if clipboard has only new lines", async () => {
|
|
const text = "\n\n\n\n\n";
|
|
pasteWithCtrlCmdV(text);
|
|
await waitFor(async () => {
|
|
await sleep(50); // elements lenght will always be zero if we don't wait, since paste is async
|
|
expect(h.elements.length).toEqual(0);
|
|
});
|
|
});
|
|
|
|
it("should space items correctly", async () => {
|
|
const elementsMap = arrayToMap(h.elements);
|
|
|
|
const text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
|
|
const lineHeightPx =
|
|
getLineHeightInPx(
|
|
h.app.state.currentItemFontSize,
|
|
getDefaultLineHeight(h.state.currentItemFontFamily),
|
|
) +
|
|
10 / h.app.state.zoom.value;
|
|
mouse.moveTo(100, 100);
|
|
pasteWithCtrlCmdV(text);
|
|
await waitFor(async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const [fx, firstElY] = getElementBounds(h.elements[0], elementsMap);
|
|
for (let i = 1; i < h.elements.length; i++) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const [fx, elY] = getElementBounds(h.elements[i], elementsMap);
|
|
expect(elY).toEqual(firstElY + lineHeightPx * i);
|
|
}
|
|
});
|
|
});
|
|
|
|
it("should leave a space for blank new lines", async () => {
|
|
const elementsMap = arrayToMap(h.elements);
|
|
const text = "hkhkjhki\n\njgkjhffjh";
|
|
const lineHeightPx =
|
|
getLineHeightInPx(
|
|
h.app.state.currentItemFontSize,
|
|
getDefaultLineHeight(h.state.currentItemFontFamily),
|
|
) +
|
|
10 / h.app.state.zoom.value;
|
|
mouse.moveTo(100, 100);
|
|
pasteWithCtrlCmdV(text);
|
|
|
|
await waitFor(async () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const [fx, firstElY] = getElementBounds(h.elements[0], elementsMap);
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const [lx, lastElY] = getElementBounds(h.elements[1], elementsMap);
|
|
expect(lastElY).toEqual(firstElY + lineHeightPx * 2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("paste text as a single element", () => {
|
|
it("should create single text element when copying text with Ctrl/Cmd+Shift+V", async () => {
|
|
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
|
pasteWithCtrlCmdShiftV(text);
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toEqual(1);
|
|
});
|
|
});
|
|
it("should not create any element when only new lines in clipboard", async () => {
|
|
const text = "\n\n\n\n";
|
|
pasteWithCtrlCmdShiftV(text);
|
|
await waitFor(async () => {
|
|
await sleep(50);
|
|
expect(h.elements.length).toEqual(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Paste bound text container", () => {
|
|
const container = {
|
|
type: "ellipse",
|
|
id: "container-id",
|
|
x: 554.984375,
|
|
y: 196.0234375,
|
|
width: 166,
|
|
height: 187.01953125,
|
|
roundness: { type: 2 },
|
|
boundElements: [{ type: "text", id: "text-id" }],
|
|
};
|
|
const textElement = {
|
|
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",
|
|
baseline: 168,
|
|
textAlign: "center",
|
|
verticalAlign: "middle",
|
|
containerId: container.id,
|
|
originalText:
|
|
"Excalidraw is a virtual opensource whiteboard for sketching hand-drawn like diagrams",
|
|
};
|
|
|
|
it("should fix ellipse bounding box", async () => {
|
|
const data = JSON.stringify({
|
|
type: "excalidraw/clipboard",
|
|
elements: [container, textElement],
|
|
});
|
|
pasteWithCtrlCmdShiftV(data);
|
|
|
|
await waitFor(async () => {
|
|
await sleep(1);
|
|
expect(h.elements.length).toEqual(2);
|
|
const container = h.elements[0];
|
|
expect(container.height).toBe(368);
|
|
expect(container.width).toBe(166);
|
|
});
|
|
});
|
|
|
|
it("should fix diamond bounding box", async () => {
|
|
const data = JSON.stringify({
|
|
type: "excalidraw/clipboard",
|
|
elements: [
|
|
{
|
|
...container,
|
|
type: "diamond",
|
|
},
|
|
textElement,
|
|
],
|
|
});
|
|
pasteWithCtrlCmdShiftV(data);
|
|
|
|
await waitFor(async () => {
|
|
await sleep(1);
|
|
expect(h.elements.length).toEqual(2);
|
|
const container = h.elements[0];
|
|
expect(container.height).toBe(770);
|
|
expect(container.width).toBe(166);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("pasting & frames", () => {
|
|
it("should add pasted elements to frame under cursor", async () => {
|
|
const frame = API.createElement({
|
|
type: "frame",
|
|
width: 100,
|
|
height: 100,
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
const rect = API.createElement({ type: "rectangle" });
|
|
|
|
h.elements = [frame];
|
|
|
|
const clipboardJSON = await serializeAsClipboardJSON({
|
|
elements: [rect],
|
|
files: null,
|
|
});
|
|
|
|
mouse.moveTo(50, 50);
|
|
|
|
pasteWithCtrlCmdV(clipboardJSON);
|
|
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toBe(2);
|
|
expect(h.elements[1].type).toBe(rect.type);
|
|
expect(h.elements[1].frameId).toBe(frame.id);
|
|
});
|
|
});
|
|
|
|
it("should filter out elements not overlapping frame", async () => {
|
|
const frame = API.createElement({
|
|
type: "frame",
|
|
width: 100,
|
|
height: 100,
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
const rect = API.createElement({
|
|
type: "rectangle",
|
|
width: 50,
|
|
height: 50,
|
|
});
|
|
const rect2 = API.createElement({
|
|
type: "rectangle",
|
|
width: 50,
|
|
height: 50,
|
|
x: 100,
|
|
y: 100,
|
|
});
|
|
|
|
h.elements = [frame];
|
|
|
|
const clipboardJSON = await serializeAsClipboardJSON({
|
|
elements: [rect, rect2],
|
|
files: null,
|
|
});
|
|
|
|
mouse.moveTo(90, 90);
|
|
|
|
pasteWithCtrlCmdV(clipboardJSON);
|
|
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toBe(3);
|
|
expect(h.elements[1].type).toBe(rect.type);
|
|
expect(h.elements[1].frameId).toBe(frame.id);
|
|
expect(h.elements[2].type).toBe(rect2.type);
|
|
expect(h.elements[2].frameId).toBe(null);
|
|
});
|
|
});
|
|
|
|
it("should not filter out elements not overlapping frame if part of group", async () => {
|
|
const frame = API.createElement({
|
|
type: "frame",
|
|
width: 100,
|
|
height: 100,
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
const rect = API.createElement({
|
|
type: "rectangle",
|
|
width: 50,
|
|
height: 50,
|
|
groupIds: ["g1"],
|
|
});
|
|
const rect2 = API.createElement({
|
|
type: "rectangle",
|
|
width: 50,
|
|
height: 50,
|
|
x: 100,
|
|
y: 100,
|
|
groupIds: ["g1"],
|
|
});
|
|
|
|
h.elements = [frame];
|
|
|
|
const clipboardJSON = await serializeAsClipboardJSON({
|
|
elements: [rect, rect2],
|
|
files: null,
|
|
});
|
|
|
|
mouse.moveTo(90, 90);
|
|
|
|
pasteWithCtrlCmdV(clipboardJSON);
|
|
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toBe(3);
|
|
expect(h.elements[1].type).toBe(rect.type);
|
|
expect(h.elements[1].frameId).toBe(frame.id);
|
|
expect(h.elements[2].type).toBe(rect2.type);
|
|
expect(h.elements[2].frameId).toBe(frame.id);
|
|
});
|
|
});
|
|
|
|
it("should not filter out other frames and their children", async () => {
|
|
const frame = API.createElement({
|
|
type: "frame",
|
|
width: 100,
|
|
height: 100,
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
const rect = API.createElement({
|
|
type: "rectangle",
|
|
width: 50,
|
|
height: 50,
|
|
groupIds: ["g1"],
|
|
});
|
|
|
|
const frame2 = API.createElement({
|
|
type: "frame",
|
|
width: 75,
|
|
height: 75,
|
|
x: 0,
|
|
y: 0,
|
|
});
|
|
const rect2 = API.createElement({
|
|
type: "rectangle",
|
|
width: 50,
|
|
height: 50,
|
|
x: 55,
|
|
y: 55,
|
|
frameId: frame2.id,
|
|
});
|
|
|
|
h.elements = [frame];
|
|
|
|
const clipboardJSON = await serializeAsClipboardJSON({
|
|
elements: [rect, rect2, frame2],
|
|
files: null,
|
|
});
|
|
|
|
mouse.moveTo(90, 90);
|
|
|
|
pasteWithCtrlCmdV(clipboardJSON);
|
|
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toBe(4);
|
|
expect(h.elements[1].type).toBe(rect.type);
|
|
expect(h.elements[1].frameId).toBe(frame.id);
|
|
expect(h.elements[2].type).toBe(rect2.type);
|
|
expect(h.elements[2].frameId).toBe(h.elements[3].id);
|
|
expect(h.elements[3].type).toBe(frame2.type);
|
|
expect(h.elements[3].frameId).toBe(null);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("clipboard - pasting mermaid definition", () => {
|
|
beforeAll(() => {
|
|
mockMermaidToExcalidraw({
|
|
parseMermaidToExcalidraw: async (definition) => {
|
|
const lines = definition.split("\n");
|
|
return new Promise((resolve, reject) => {
|
|
if (lines.some((line) => line === "flowchart TD")) {
|
|
resolve({
|
|
elements: [
|
|
{
|
|
id: "rect1",
|
|
type: "rectangle",
|
|
groupIds: [],
|
|
x: 0,
|
|
y: 0,
|
|
width: 69.703125,
|
|
height: 44,
|
|
strokeWidth: 2,
|
|
label: {
|
|
groupIds: [],
|
|
text: "A",
|
|
fontSize: 20,
|
|
},
|
|
link: null,
|
|
},
|
|
],
|
|
});
|
|
} else {
|
|
reject(new Error("ERROR"));
|
|
}
|
|
});
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should detect and paste as mermaid", async () => {
|
|
const text = "flowchart TD\nA";
|
|
|
|
pasteWithCtrlCmdV(text);
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toEqual(2);
|
|
expect(h.elements).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ type: "rectangle" }),
|
|
expect.objectContaining({ type: "text", text: "A" }),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should support directives", async () => {
|
|
const text = "%%{init: { **config** } }%%\nflowchart TD\nA";
|
|
|
|
pasteWithCtrlCmdV(text);
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toEqual(2);
|
|
expect(h.elements).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ type: "rectangle" }),
|
|
expect.objectContaining({ type: "text", text: "A" }),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
|
|
it("should paste as normal text if invalid mermaid", async () => {
|
|
const text = "flowchart TD xx\nA";
|
|
pasteWithCtrlCmdV(text);
|
|
await waitFor(() => {
|
|
expect(h.elements.length).toEqual(2);
|
|
expect(h.elements).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ type: "text", text: "flowchart TD xx" }),
|
|
expect.objectContaining({ type: "text", text: "A" }),
|
|
]),
|
|
);
|
|
});
|
|
});
|
|
});
|