Sfoglia il codice sorgente

feat: changed text copy/paste behaviour (#5786)

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Antonio Della Fortuna <a.dellafortuna00@gmail.com>
Antonio Della Fortuna 2 anni fa
parent
commit
baf9651d34

+ 3 - 1
src/actions/actionFlip.ts

@@ -14,6 +14,7 @@ import {
 } from "../element/bounds";
 import { isLinearElement } from "../element/typeChecks";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import { KEYS } from "../keys";
 
 const enableActionFlipHorizontal = (
   elements: readonly ExcalidrawElement[],
@@ -63,7 +64,8 @@ export const actionFlipVertical = register({
       commitToHistory: true,
     };
   },
-  keyTest: (event) => event.shiftKey && event.code === "KeyV",
+  keyTest: (event) =>
+    event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
   contextItemLabel: "labels.flipVertical",
   contextItemPredicate: (elements, appState) =>
     enableActionFlipVertical(elements, appState),

+ 20 - 6
src/clipboard.ts

@@ -8,6 +8,7 @@ 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 { normalizeText } from "./element/textElement";
 
 type ElementsClipboard = {
   type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -109,16 +110,16 @@ const parsePotentialSpreadsheet = (
  * Retrieves content from system clipboard (either from ClipboardEvent or
  *  via async clipboard API if supported)
  */
-const getSystemClipboard = async (
+export const getSystemClipboard = async (
   event: ClipboardEvent | null,
 ): Promise<string> => {
   try {
     const text = event
-      ? event.clipboardData?.getData("text/plain").trim()
+      ? event.clipboardData?.getData("text/plain")
       : probablySupportsClipboardReadText &&
         (await navigator.clipboard.readText());
 
-    return text || "";
+    return normalizeText(text || "").trim();
   } catch {
     return "";
   }
@@ -129,19 +130,24 @@ const getSystemClipboard = async (
  */
 export const parseClipboard = async (
   event: ClipboardEvent | null,
+  isPlainPaste = false,
 ): Promise<ClipboardData> => {
   const systemClipboard = await getSystemClipboard(event);
 
   // if system clipboard empty, couldn't be resolved, or contains previously
   // copied excalidraw scene as SVG, fall back to previously copied excalidraw
   // elements
-  if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
+  if (
+    !systemClipboard ||
+    (!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
+  ) {
     return getAppClipboard();
   }
 
   // if system clipboard contains spreadsheet, use it even though it's
   // technically possible it's staler than in-app clipboard
-  const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
+  const spreadsheetResult =
+    !isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
   if (spreadsheetResult) {
     return spreadsheetResult;
   }
@@ -154,6 +160,9 @@ export const parseClipboard = async (
       return {
         elements: systemClipboardData.elements,
         files: systemClipboardData.files,
+        text: isPlainPaste
+          ? JSON.stringify(systemClipboardData.elements, null, 2)
+          : undefined,
       };
     }
   } catch (e) {}
@@ -161,7 +170,12 @@ export const parseClipboard = async (
   // unless we set a flag to prefer in-app clipboard because browser didn't
   // support storing to system clipboard on copy
   return PREFER_APP_CLIPBOARD && appClipboardData.elements
-    ? appClipboardData
+    ? {
+        ...appClipboardData,
+        text: isPlainPaste
+          ? JSON.stringify(appClipboardData.elements, null, 2)
+          : undefined,
+      }
     : { text: systemClipboard };
 };
 

+ 93 - 11
src/components/App.tsx

@@ -222,6 +222,7 @@ import {
   updateObject,
   setEraserCursor,
   updateActiveTool,
+  getShortcutKey,
 } from "../utils";
 import ContextMenu, { ContextMenuOption } from "./ContextMenu";
 import LayerUI from "./LayerUI";
@@ -249,6 +250,7 @@ import throttle from "lodash.throttle";
 import { fileOpen, FileSystemHandle } from "../data/filesystem";
 import {
   bindTextToShapeAfterDuplication,
+  getApproxLineHeight,
   getApproxMinLineHeight,
   getApproxMinLineWidth,
   getBoundTextElement,
@@ -326,6 +328,10 @@ let invalidateContextMenu = false;
 // to rAF. See #5439
 let THROTTLE_NEXT_RENDER = true;
 
+let IS_PLAIN_PASTE = false;
+let IS_PLAIN_PASTE_TIMER = 0;
+let PLAIN_PASTE_TOAST_SHOWN = false;
+
 let lastPointerUp: ((event: any) => void) | null = null;
 const gesture: Gesture = {
   pointers: new Map(),
@@ -1452,6 +1458,8 @@ class App extends React.Component<AppProps, AppState> {
 
   private pasteFromClipboard = withBatchedUpdates(
     async (event: ClipboardEvent | null) => {
+      const isPlainPaste = !!(IS_PLAIN_PASTE && event);
+
       // #686
       const target = document.activeElement;
       const isExcalidrawActive =
@@ -1462,8 +1470,6 @@ class App extends React.Component<AppProps, AppState> {
 
       const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
       if (
-        // if no ClipboardEvent supplied, assume we're pasting via contextMenu
-        // thus these checks don't make sense
         event &&
         (!(elementUnderCursor instanceof HTMLCanvasElement) ||
           isWritableElement(target))
@@ -1476,9 +1482,9 @@ class App extends React.Component<AppProps, AppState> {
       // (something something security)
       let file = event?.clipboardData?.files[0];
 
-      const data = await parseClipboard(event);
+      const data = await parseClipboard(event, isPlainPaste);
 
-      if (!file && data.text) {
+      if (!file && data.text && !isPlainPaste) {
         const string = data.text.trim();
         if (string.startsWith("<svg") && string.endsWith("</svg>")) {
           // ignore SVG validation/normalization which will be done during image
@@ -1511,9 +1517,10 @@ class App extends React.Component<AppProps, AppState> {
           console.error(error);
         }
       }
+
       if (data.errorMessage) {
         this.setState({ errorMessage: data.errorMessage });
-      } else if (data.spreadsheet) {
+      } else if (data.spreadsheet && !isPlainPaste) {
         this.setState({
           pasteDialog: {
             data: data.spreadsheet,
@@ -1521,13 +1528,14 @@ class App extends React.Component<AppProps, AppState> {
           },
         });
       } else if (data.elements) {
+        // TODO remove formatting from elements if isPlainPaste
         this.addElementsFromPasteOrLibrary({
           elements: data.elements,
           files: data.files || null,
           position: "cursor",
         });
       } else if (data.text) {
-        this.addTextFromPaste(data.text);
+        this.addTextFromPaste(data.text, isPlainPaste);
       }
       this.setActiveTool({ type: "selection" });
       event?.preventDefault();
@@ -1634,13 +1642,13 @@ class App extends React.Component<AppProps, AppState> {
     this.setActiveTool({ type: "selection" });
   };
 
-  private addTextFromPaste(text: any) {
+  private addTextFromPaste(text: string, isPlainPaste = false) {
     const { x, y } = viewportCoordsToSceneCoords(
       { clientX: cursorX, clientY: cursorY },
       this.state,
     );
 
-    const element = newTextElement({
+    const textElementProps = {
       x,
       y,
       strokeColor: this.state.currentItemStrokeColor,
@@ -1657,13 +1665,76 @@ class App extends React.Component<AppProps, AppState> {
       textAlign: this.state.currentItemTextAlign,
       verticalAlign: DEFAULT_VERTICAL_ALIGN,
       locked: false,
-    });
+    };
+
+    const LINE_GAP = 10;
+    let currentY = y;
+
+    const lines = isPlainPaste ? [text] : text.split("\n");
+    const textElements = lines.reduce(
+      (acc: ExcalidrawTextElement[], line, idx) => {
+        const text = line.trim();
+
+        if (text.length) {
+          const element = newTextElement({
+            ...textElementProps,
+            x,
+            y: currentY,
+            text,
+          });
+          acc.push(element);
+          currentY += element.height + LINE_GAP;
+        } else {
+          const prevLine = lines[idx - 1]?.trim();
+          // add paragraph only if previous line was not empty, IOW don't add
+          // more than one empty line
+          if (prevLine) {
+            const defaultLineHeight = getApproxLineHeight(
+              getFontString({
+                fontSize: textElementProps.fontSize,
+                fontFamily: textElementProps.fontFamily,
+              }),
+            );
+
+            currentY += defaultLineHeight + LINE_GAP;
+          }
+        }
+
+        return acc;
+      },
+      [],
+    );
+
+    if (textElements.length === 0) {
+      return;
+    }
 
     this.scene.replaceAllElements([
       ...this.scene.getElementsIncludingDeleted(),
-      element,
+      ...textElements,
     ]);
-    this.setState({ selectedElementIds: { [element.id]: true } });
+
+    this.setState({
+      selectedElementIds: Object.fromEntries(
+        textElements.map((el) => [el.id, true]),
+      ),
+    });
+
+    if (
+      !isPlainPaste &&
+      textElements.length > 1 &&
+      PLAIN_PASTE_TOAST_SHOWN === false &&
+      !this.device.isMobile
+    ) {
+      this.setToast({
+        message: t("toast.pasteAsSingleElement", {
+          shortcut: getShortcutKey("CtrlOrCmd+Shift+V"),
+        }),
+        duration: 5000,
+      });
+      PLAIN_PASTE_TOAST_SHOWN = true;
+    }
+
     this.history.resumeRecording();
   }
 
@@ -1873,6 +1944,17 @@ class App extends React.Component<AppProps, AppState> {
         });
       }
 
+      if (event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.V) {
+        IS_PLAIN_PASTE = event.shiftKey;
+        clearTimeout(IS_PLAIN_PASTE_TIMER);
+        // reset (100ms to be safe that we it runs after the ensuing
+        // paste event). Though, technically unnecessary to reset since we
+        // (re)set the flag before each paste event.
+        IS_PLAIN_PASTE_TIMER = window.setTimeout(() => {
+          IS_PLAIN_PASTE = false;
+        }, 100);
+      }
+
       // prevent browser zoom in input fields
       if (event[KEYS.CTRL_OR_CMD] && isWritableElement(event.target)) {
         if (event.code === CODES.MINUS || event.code === CODES.EQUAL) {

+ 4 - 0
src/components/HelpDialog.tsx

@@ -290,6 +290,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
             />
             <Shortcut
+              label={t("labels.pasteAsPlaintext")}
+              shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
+            />
+            <Shortcut
               label={t("labels.copyAsPng")}
               shortcuts={[getShortcutKey("Shift+Alt+C")]}
             />

+ 5 - 3
src/element/newElement.ts

@@ -25,6 +25,7 @@ import {
   getContainerDims,
   getContainerElement,
   measureText,
+  normalizeText,
   wrapText,
 } from "./textElement";
 import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
@@ -133,12 +134,13 @@ export const newTextElement = (
     containerId?: ExcalidrawRectangleElement["id"];
   } & ElementConstructorOpts,
 ): NonDeleted<ExcalidrawTextElement> => {
-  const metrics = measureText(opts.text, getFontString(opts));
+  const text = normalizeText(opts.text);
+  const metrics = measureText(text, getFontString(opts));
   const offsets = getTextElementPositionOffsets(opts, metrics);
   const textElement = newElementWith(
     {
       ..._newElementBase<ExcalidrawTextElement>("text", opts),
-      text: opts.text,
+      text,
       fontSize: opts.fontSize,
       fontFamily: opts.fontFamily,
       textAlign: opts.textAlign,
@@ -149,7 +151,7 @@ export const newTextElement = (
       height: metrics.height,
       baseline: metrics.baseline,
       containerId: opts.containerId || null,
-      originalText: opts.text,
+      originalText: text,
     },
     {},
   );

+ 10 - 0
src/element/textElement.ts

@@ -19,6 +19,16 @@ import { AppState } from "../types";
 import { getSelectedElements } from "../scene";
 import { isImageElement } from "./typeChecks";
 
+export const normalizeText = (text: string) => {
+  return (
+    text
+      // replace tabs with spaces so they render and measure correctly
+      .replace(/\t/g, "        ")
+      // normalize newlines
+      .replace(/\r?\n|\r/g, "\n")
+  );
+};
+
 export const redrawTextBoundingBox = (
   textElement: ExcalidrawTextElement,
   container: ExcalidrawElement | null,

+ 2 - 11
src/element/textWysiwyg.tsx

@@ -21,6 +21,7 @@ import {
   getContainerDims,
   getContainerElement,
   measureText,
+  normalizeText,
   wrapText,
 } from "./textElement";
 import {
@@ -32,16 +33,6 @@ import App from "../components/App";
 import { getMaxContainerWidth } from "./newElement";
 import { parseClipboard } from "../clipboard";
 
-const normalizeText = (text: string) => {
-  return (
-    text
-      // replace tabs with spaces so they render and measure correctly
-      .replace(/\t/g, "        ")
-      // normalize newlines
-      .replace(/\r?\n|\r/g, "\n")
-  );
-};
-
 const getTransform = (
   width: number,
   height: number,
@@ -279,7 +270,7 @@ export const textWysiwyg = ({
   if (onChange) {
     editable.onpaste = async (event) => {
       event.preventDefault();
-      const clipboardData = await parseClipboard(event);
+      const clipboardData = await parseClipboard(event, true);
       if (!clipboardData.text) {
         return;
       }

+ 3 - 1
src/locales/en.json

@@ -1,6 +1,7 @@
 {
   "labels": {
     "paste": "Paste",
+    "pasteAsPlaintext": "Paste as plaintext",
     "pasteCharts": "Paste charts",
     "selectAll": "Select all",
     "multiSelect": "Add element to selection",
@@ -392,7 +393,8 @@
     "fileSaved": "File saved.",
     "fileSavedToFilename": "Saved to {filename}",
     "canvas": "canvas",
-    "selection": "selection"
+    "selection": "selection",
+    "pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
   },
   "colors": {
     "ffffff": "White",

+ 184 - 0
src/tests/clipboard.test.tsx

@@ -0,0 +1,184 @@
+import ReactDOM from "react-dom";
+import { render, waitFor, GlobalTestState } from "./test-utils";
+import { Pointer, Keyboard } from "./helpers/ui";
+import ExcalidrawApp from "../excalidraw-app";
+import { KEYS } from "../keys";
+import { getApproxLineHeight } from "../element/textElement";
+import { getFontString } from "../utils";
+import { getElementBounds } from "../element";
+import { NormalizedZoomValue } from "../types";
+
+const { h } = window;
+
+const mouse = new Pointer("mouse");
+
+jest.mock("../keys.ts", () => {
+  const actual = jest.requireActual("../keys.ts");
+  return {
+    __esmodule: true,
+    ...actual,
+    isDarwin: false,
+    KEYS: {
+      ...actual.KEYS,
+      CTRL_OR_CMD: "ctrlKey",
+    },
+  };
+});
+
+const setClipboardText = (text: string) => {
+  Object.assign(navigator, {
+    clipboard: {
+      readText: () => text,
+    },
+  });
+};
+
+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: [],
+  };
+
+  document.dispatchEvent(clipboardEvent);
+};
+
+const pasteWithCtrlCmdShiftV = () => {
+  Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
+    //triggering keydown with an empty clipboard
+    Keyboard.keyPress(KEYS.V);
+    //triggering paste event with faked clipboard
+    sendPasteEvent();
+  });
+};
+
+const pasteWithCtrlCmdV = () => {
+  Keyboard.withModifierKeys({ ctrl: true }, () => {
+    //triggering keydown with an empty clipboard
+    Keyboard.keyPress(KEYS.V);
+    //triggering paste event with faked clipboard
+    sendPasteEvent();
+  });
+};
+
+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(<ExcalidrawApp />);
+  h.app.setAppState({ zoom: { value: 1 as NormalizedZoomValue } });
+  setClipboardText("");
+  Object.assign(document, {
+    elementFromPoint: () => GlobalTestState.canvas,
+  });
+});
+
+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";
+    setClipboardText(text);
+    pasteWithCtrlCmdV();
+    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";
+    setClipboardText(text);
+    pasteWithCtrlCmdV();
+    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";
+    setClipboardText(text);
+    pasteWithCtrlCmdV();
+    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 text = "hkhkjhki\njgkjhffjh\njgkjhffjh";
+    const lineHeight =
+      getApproxLineHeight(
+        getFontString({
+          fontSize: h.app.state.currentItemFontSize,
+          fontFamily: h.app.state.currentItemFontFamily,
+        }),
+      ) +
+      10 / h.app.state.zoom.value;
+    mouse.moveTo(100, 100);
+    setClipboardText(text);
+    pasteWithCtrlCmdV();
+    await waitFor(async () => {
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      const [fx, firstElY] = getElementBounds(h.elements[0]);
+      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]);
+        expect(elY).toEqual(firstElY + lineHeight * i);
+      }
+    });
+  });
+
+  it("should leave a space for blank new lines", async () => {
+    const text = "hkhkjhki\n\njgkjhffjh";
+    const lineHeight =
+      getApproxLineHeight(
+        getFontString({
+          fontSize: h.app.state.currentItemFontSize,
+          fontFamily: h.app.state.currentItemFontFamily,
+        }),
+      ) +
+      10 / h.app.state.zoom.value;
+    mouse.moveTo(100, 100);
+    setClipboardText(text);
+    pasteWithCtrlCmdV();
+    await waitFor(async () => {
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      const [fx, firstElY] = getElementBounds(h.elements[0]);
+      // eslint-disable-next-line @typescript-eslint/no-unused-vars
+      const [lx, lastElY] = getElementBounds(h.elements[1]);
+      expect(lastElY).toEqual(firstElY + lineHeight * 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";
+    setClipboardText(text);
+    pasteWithCtrlCmdShiftV();
+    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";
+    setClipboardText(text);
+    pasteWithCtrlCmdShiftV();
+    await waitFor(async () => {
+      await sleep(50);
+      expect(h.elements.length).toEqual(0);
+    });
+  });
+});