Selaa lähdekoodia

feat: retain `seed` on shift-paste (#6509)

thanks for the review 👍
David Luzar 2 vuotta sitten
vanhempi
commit
d35386755f

+ 1 - 1
src/actions/actionClipboard.tsx

@@ -18,7 +18,7 @@ export const actionCopy = register({
   perform: (elements, appState, _, app) => {
     const selectedElements = getSelectedElements(elements, appState, true);
 
-    copyToClipboard(selectedElements, appState, app.files);
+    copyToClipboard(selectedElements, app.files);
 
     return {
       commitToHistory: false,

+ 27 - 11
src/clipboard.ts

@@ -2,12 +2,12 @@ import {
   ExcalidrawElement,
   NonDeletedExcalidrawElement,
 } from "./element/types";
-import { AppState, BinaryFiles } from "./types";
+import { BinaryFiles } from "./types";
 import { SVG_EXPORT_TAG } from "./scene/export";
 import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
 import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
 import { isInitializedImageElement } from "./element/typeChecks";
-import { isPromiseLike } from "./utils";
+import { isPromiseLike, isTestEnv } from "./utils";
 
 type ElementsClipboard = {
   type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -55,24 +55,40 @@ const clipboardContainsElements = (
 
 export const copyToClipboard = async (
   elements: readonly NonDeletedExcalidrawElement[],
-  appState: AppState,
   files: BinaryFiles | null,
 ) => {
+  let foundFile = false;
+
+  const _files = elements.reduce((acc, element) => {
+    if (isInitializedImageElement(element)) {
+      foundFile = true;
+      if (files && files[element.fileId]) {
+        acc[element.fileId] = files[element.fileId];
+      }
+    }
+    return acc;
+  }, {} as BinaryFiles);
+
+  if (foundFile && !files) {
+    console.warn(
+      "copyToClipboard: attempting to file element(s) without providing associated `files` object.",
+    );
+  }
+
   // select binded text elements when copying
   const contents: ElementsClipboard = {
     type: EXPORT_DATA_TYPES.excalidrawClipboard,
     elements,
-    files: files
-      ? elements.reduce((acc, element) => {
-          if (isInitializedImageElement(element) && files[element.fileId]) {
-            acc[element.fileId] = files[element.fileId];
-          }
-          return acc;
-        }, {} as BinaryFiles)
-      : undefined,
+    files: files ? _files : undefined,
   };
   const json = JSON.stringify(contents);
+
+  if (isTestEnv()) {
+    return json;
+  }
+
   CLIPBOARD = json;
+
   try {
     PREFER_APP_CLIPBOARD = false;
     await copyTextToSystemClipboard(json);

+ 5 - 0
src/components/App.tsx

@@ -1590,6 +1590,7 @@ class App extends React.Component<AppProps, AppState> {
           elements: data.elements,
           files: data.files || null,
           position: "cursor",
+          retainSeed: isPlainPaste,
         });
       } else if (data.text) {
         this.addTextFromPaste(data.text, isPlainPaste);
@@ -1603,6 +1604,7 @@ class App extends React.Component<AppProps, AppState> {
     elements: readonly ExcalidrawElement[];
     files: BinaryFiles | null;
     position: { clientX: number; clientY: number } | "cursor" | "center";
+    retainSeed?: boolean;
   }) => {
     const elements = restoreElements(opts.elements, null);
     const [minX, minY, maxX, maxY] = getCommonBounds(elements);
@@ -1640,6 +1642,9 @@ class App extends React.Component<AppProps, AppState> {
           y: element.y + gridY - minY,
         });
       }),
+      {
+        randomizeSeed: !opts.retainSeed,
+      },
     );
 
     const nextElements = [

+ 1 - 1
src/components/LibraryMenuItems.tsx

@@ -102,7 +102,7 @@ const LibraryMenuItems = ({
         ...item,
         // duplicate each library item before inserting on canvas to confine
         // ids and bindings to each library item. See #6465
-        elements: duplicateElements(item.elements),
+        elements: duplicateElements(item.elements, { randomizeSeed: true }),
       };
     });
   };

+ 15 - 2
src/element/newElement.ts

@@ -20,7 +20,7 @@ import {
   isTestEnv,
 } from "../utils";
 import { randomInteger, randomId } from "../random";
-import { mutateElement, newElementWith } from "./mutateElement";
+import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
 import { getNewGroupIdsForDuplication } from "../groups";
 import { AppState } from "../types";
 import { getElementAbsoluteCoords } from ".";
@@ -539,8 +539,16 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
  * it's advised to supply the whole elements array, or sets of elements that
  * are encapsulated (such as library items), if the purpose is to retain
  * bindings to the cloned elements intact.
+ *
+ * NOTE by default does not randomize or regenerate anything except the id.
  */
-export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
+export const duplicateElements = (
+  elements: readonly ExcalidrawElement[],
+  opts?: {
+    /** NOTE also updates version flags and `updated` */
+    randomizeSeed: boolean;
+  },
+) => {
   const clonedElements: ExcalidrawElement[] = [];
 
   const origElementsMap = arrayToMap(elements);
@@ -574,6 +582,11 @@ export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
 
     clonedElement.id = maybeGetNewId(element.id)!;
 
+    if (opts?.randomizeSeed) {
+      clonedElement.seed = randomInteger();
+      bumpVersion(clonedElement);
+    }
+
     if (clonedElement.groupIds) {
       clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
         if (!groupNewIdsMap.has(groupId)) {

+ 1 - 9
src/packages/utils.ts

@@ -220,15 +220,7 @@ export const exportToClipboard = async (
   } else if (opts.type === "png") {
     await copyBlobToClipboardAsPng(exportToBlob(opts));
   } else if (opts.type === "json") {
-    const appState = {
-      offsetTop: 0,
-      offsetLeft: 0,
-      width: 0,
-      height: 0,
-      ...getDefaultAppState(),
-      ...opts.appState,
-    };
-    await copyToClipboard(opts.elements, appState, opts.files);
+    await copyToClipboard(opts.elements, opts.files);
   } else {
     throw new Error("Invalid export type");
   }

+ 42 - 19
src/tests/clipboard.test.tsx

@@ -1,5 +1,10 @@
 import ReactDOM from "react-dom";
-import { render, waitFor, GlobalTestState } from "./test-utils";
+import {
+  render,
+  waitFor,
+  GlobalTestState,
+  createPasteEvent,
+} from "./test-utils";
 import { Pointer, Keyboard } from "./helpers/ui";
 import ExcalidrawApp from "../excalidraw-app";
 import { KEYS } from "../keys";
@@ -9,6 +14,8 @@ import {
 } from "../element/textElement";
 import { getElementBounds } from "../element";
 import { NormalizedZoomValue } from "../types";
+import { API } from "./helpers/api";
+import { copyToClipboard } from "../clipboard";
 
 const { h } = window;
 
@@ -35,38 +42,28 @@ const setClipboardText = (text: string) => {
   });
 };
 
-const sendPasteEvent = () => {
-  const clipboardEvent = new Event("paste", {
-    bubbles: true,
-    cancelable: true,
-    composed: true,
-  });
-
-  // set `clipboardData` properties.
-  // @ts-ignore
-  clipboardEvent.clipboardData = {
-    getData: () => window.navigator.clipboard.readText(),
-    files: [],
-  };
-
+const sendPasteEvent = (text?: string) => {
+  const clipboardEvent = createPasteEvent(
+    text || (() => window.navigator.clipboard.readText()),
+  );
   document.dispatchEvent(clipboardEvent);
 };
 
-const pasteWithCtrlCmdShiftV = () => {
+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();
+    sendPasteEvent(text);
   });
 };
 
-const pasteWithCtrlCmdV = () => {
+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();
+    sendPasteEvent(text);
   });
 };
 
@@ -89,6 +86,32 @@ beforeEach(async () => {
   });
 });
 
+describe("general paste behavior", () => {
+  it("should randomize seed on paste", async () => {
+    const rectangle = API.createElement({ type: "rectangle" });
+    const clipboardJSON = (await copyToClipboard([rectangle], 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 copyToClipboard([rectangle], 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";

+ 7 - 14
src/tests/flip.test.tsx

@@ -1,5 +1,10 @@
 import ReactDOM from "react-dom";
-import { GlobalTestState, render, waitFor } from "./test-utils";
+import {
+  createPasteEvent,
+  GlobalTestState,
+  render,
+  waitFor,
+} from "./test-utils";
 import { UI, Pointer } from "./helpers/ui";
 import { API } from "./helpers/api";
 import { actionFlipHorizontal, actionFlipVertical } from "../actions";
@@ -680,19 +685,7 @@ describe("freedraw", () => {
 describe("image", () => {
   const createImage = async () => {
     const sendPasteEvent = (file?: File) => {
-      const clipboardEvent = new Event("paste", {
-        bubbles: true,
-        cancelable: true,
-        composed: true,
-      });
-
-      // set `clipboardData` properties.
-      // @ts-ignore
-      clipboardEvent.clipboardData = {
-        getData: () => window.navigator.clipboard.readText(),
-        files: [file],
-      };
-
+      const clipboardEvent = createPasteEvent("", file ? [file] : []);
       document.dispatchEvent(clipboardEvent);
     };
 

+ 21 - 0
src/tests/test-utils.ts

@@ -190,3 +190,24 @@ export const toggleMenu = (container: HTMLElement) => {
   // open menu
   fireEvent.click(container.querySelector(".dropdown-menu-button")!);
 };
+
+export const createPasteEvent = (
+  text:
+    | string
+    | /* getData function */ ((type: string) => string | Promise<string>),
+  files?: File[],
+) => {
+  return Object.assign(
+    new Event("paste", {
+      bubbles: true,
+      cancelable: true,
+      composed: true,
+    }),
+    {
+      clipboardData: {
+        getData: typeof text === "string" ? () => text : text,
+        files: files || [],
+      },
+    },
+  );
+};