From 6ec259a0e71174651bae95d4628138bf6fd68742 Mon Sep 17 00:00:00 2001
From: kj_sh604
Date: Sun, 15 Mar 2026 16:19:35 -0400
Subject: refactor: packages/

---
 packages/excalidraw/tests/clipboard.test.tsx | 521 +++++++++++++++++++++++++++
 1 file changed, 521 insertions(+)
 create mode 100644 packages/excalidraw/tests/clipboard.test.tsx

(limited to 'packages/excalidraw/tests/clipboard.test.tsx')

diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx
new file mode 100644
index 0000000..c2c0a5e
--- /dev/null
+++ b/packages/excalidraw/tests/clipboard.test.tsx
@@ -0,0 +1,521 @@
+import React from "react";
+import { vi } from "vitest";
+import {
+  render,
+  waitFor,
+  GlobalTestState,
+  unmountComponent,
+} from "./test-utils";
+import { Pointer, Keyboard } from "./helpers/ui";
+import { Excalidraw } from "../index";
+import { KEYS } from "../keys";
+import { getLineHeightInPx } from "../element/textMeasurements";
+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";
+import { getLineHeight } from "../fonts";
+
+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 () => {
+  unmountComponent();
+
+  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,
+        getLineHeight(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,
+        getLineHeight(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" });
+
+    API.setElements([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,
+    });
+
+    API.setElements([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"],
+    });
+
+    API.setElements([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,
+    });
+
+    API.setElements([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" }),
+        ]),
+      );
+    });
+  });
+});
-- 
cgit v1.2.3

