Procházet zdrojové kódy

rewrite clipboard handling (#689)

David Luzar před 5 roky
rodič
revize
954d805cb3
6 změnil soubory, kde provedl 263 přidání a 103 odebrání
  1. 1 1
      .lintstagedrc.js
  2. 144 0
      src/clipboard.ts
  3. 2 6
      src/components/ExportDialog.tsx
  4. 83 79
      src/index.tsx
  5. 16 17
      src/scene/data.ts
  6. 17 0
      src/utils.ts

+ 1 - 1
.lintstagedrc.js

@@ -7,7 +7,7 @@ const cli = new CLIEngine({});
 module.exports = {
   "*.{js,ts,tsx}": files => {
     return (
-      "eslint --fix" + files.filter(file => !cli.isPathIgnored(file)).join(" ")
+      "eslint --fix " + files.filter(file => !cli.isPathIgnored(file)).join(" ")
     );
   },
   "*.{css,scss,json,md,html,yml}": ["prettier --write"],

+ 144 - 0
src/clipboard.ts

@@ -0,0 +1,144 @@
+import { ExcalidrawElement } from "./element/types";
+
+let CLIPBOARD = "";
+let PREFER_APP_CLIPBOARD = false;
+
+export const probablySupportsClipboardWriteText =
+  "clipboard" in navigator && "writeText" in navigator.clipboard;
+
+export const probablySupportsClipboardBlob =
+  "clipboard" in navigator &&
+  "write" in navigator.clipboard &&
+  "ClipboardItem" in window &&
+  "toBlob" in HTMLCanvasElement.prototype;
+
+export async function copyToAppClipboard(
+  elements: readonly ExcalidrawElement[],
+) {
+  CLIPBOARD = JSON.stringify(
+    elements
+      .filter(element => element.isSelected)
+      .map(({ shape, ...el }) => el),
+  );
+  try {
+    // when copying to in-app clipboard, clear system clipboard so that if
+    //  system clip contains text on paste we know it was copied *after* user
+    //  copied elements, and thus we should prefer the text content.
+    await copyTextToSystemClipboard(null);
+    PREFER_APP_CLIPBOARD = false;
+  } catch (err) {
+    // if clearing system clipboard didn't work, we should prefer in-app
+    //  clipboard even if there's text in system clipboard on paste, because
+    //  we can't be sure of the order of copy operations
+    PREFER_APP_CLIPBOARD = true;
+  }
+}
+
+export function getAppClipboard(): {
+  elements?: readonly ExcalidrawElement[];
+} {
+  try {
+    const clipboardElements = JSON.parse(CLIPBOARD);
+
+    if (
+      Array.isArray(clipboardElements) &&
+      clipboardElements.length > 0 &&
+      clipboardElements[0].type // need to implement a better check here...
+    ) {
+      return { elements: clipboardElements };
+    }
+  } catch (err) {}
+
+  return {};
+}
+
+export function parseClipboardEvent(
+  e: ClipboardEvent,
+): {
+  text?: string;
+  elements?: readonly ExcalidrawElement[];
+} {
+  try {
+    const text = e.clipboardData?.getData("text/plain").trim();
+    if (text && !PREFER_APP_CLIPBOARD) {
+      return { text };
+      // eslint-disable-next-line no-else-return
+    } else {
+      return getAppClipboard();
+    }
+  } catch (e) {}
+
+  return {};
+}
+
+export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
+  return new Promise((resolve, reject) => {
+    try {
+      canvas.toBlob(async function(blob: any) {
+        try {
+          await navigator.clipboard.write([
+            new window.ClipboardItem({ "image/png": blob }),
+          ]);
+          resolve();
+        } catch (err) {
+          reject(err);
+        }
+      });
+    } catch (err) {
+      reject(err);
+    }
+  });
+}
+
+export async function copyTextToSystemClipboard(text: string | null) {
+  let copied = false;
+  if (probablySupportsClipboardWriteText) {
+    try {
+      // NOTE: doesn't work on FF on non-HTTPS domains, or when document
+      //  not focused
+      await navigator.clipboard.writeText(text || "");
+      copied = true;
+    } catch (err) {}
+  }
+
+  // Note that execCommand doesn't allow copying empty strings, so if we're
+  //  clearing clipboard using this API, we must copy at least an empty char
+  if (!copied && !copyTextViaExecCommand(text || " ")) {
+    throw new Error("couldn't copy");
+  }
+}
+
+// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
+function copyTextViaExecCommand(text: string) {
+  const isRTL = document.documentElement.getAttribute("dir") === "rtl";
+
+  const textarea = document.createElement("textarea");
+
+  textarea.style.border = "0";
+  textarea.style.padding = "0";
+  textarea.style.margin = "0";
+  textarea.style.position = "absolute";
+  textarea.style[isRTL ? "right" : "left"] = "-9999px";
+  const yPosition = window.pageYOffset || document.documentElement.scrollTop;
+  textarea.style.top = `${yPosition}px`;
+  // Prevent zooming on iOS
+  textarea.style.fontSize = "12pt";
+
+  textarea.setAttribute("readonly", "");
+  textarea.value = text;
+
+  document.body.appendChild(textarea);
+
+  let success = false;
+
+  try {
+    textarea.select();
+    textarea.setSelectionRange(0, textarea.value.length);
+
+    success = document.execCommand("copy");
+  } catch (err) {}
+
+  textarea.remove();
+
+  return success;
+}

+ 2 - 6
src/components/ExportDialog.tsx

@@ -15,11 +15,7 @@ import { t } from "../i18n";
 
 import { KEYS } from "../keys";
 
-const probablySupportsClipboard =
-  "toBlob" in HTMLCanvasElement.prototype &&
-  "clipboard" in navigator &&
-  "write" in navigator.clipboard &&
-  "ClipboardItem" in window;
+import { probablySupportsClipboardBlob } from "../clipboard";
 
 const scales = [1, 2, 3];
 const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
@@ -145,7 +141,7 @@ function ExportModal({
                 aria-label={t("buttons.exportToSvg")}
                 onClick={() => onExportToSvg(exportedElements, scale)}
               />
-              {probablySupportsClipboard && (
+              {probablySupportsClipboardBlob && (
                 <ToolButton
                   type="button"
                   icon={clipboard}

+ 83 - 79
src/index.tsx

@@ -45,11 +45,11 @@ import { ExcalidrawElement } from "./element/types";
 
 import {
   isInputLike,
+  isWritableElement,
   debounce,
   capitalizeString,
   distance,
   distance2d,
-  isToolIcon,
 } from "./utils";
 import { KEYS, isArrowKey } from "./keys";
 
@@ -100,6 +100,12 @@ import { t, languages, setLanguage, getLanguage } from "./i18n";
 import { StoredScenesList } from "./components/StoredScenesList";
 import { HintViewer } from "./components/HintViewer";
 
+import {
+  getAppClipboard,
+  copyToAppClipboard,
+  parseClipboardEvent,
+} from "./clipboard";
+
 let { elements } = createScene();
 const { history } = createHistory();
 
@@ -222,42 +228,61 @@ export class App extends React.Component<any, AppState> {
   };
 
   private onCut = (e: ClipboardEvent) => {
-    if (isInputLike(e.target) && !isToolIcon(e.target)) {
+    if (isWritableElement(e.target)) {
       return;
     }
-    e.clipboardData?.setData(
-      "text/plain",
-      JSON.stringify(
-        elements
-          .filter(element => element.isSelected)
-          .map(({ shape, ...el }) => el),
-      ),
-    );
+    copyToAppClipboard(elements);
     elements = deleteSelectedElements(elements);
     this.setState({});
     e.preventDefault();
   };
   private onCopy = (e: ClipboardEvent) => {
-    if (isInputLike(e.target) && !isToolIcon(e.target)) {
+    if (isWritableElement(e.target)) {
       return;
     }
-    e.clipboardData?.setData(
-      "text/plain",
-      JSON.stringify(
-        elements
-          .filter(element => element.isSelected)
-          .map(({ shape, ...el }) => el),
-      ),
-    );
+    copyToAppClipboard(elements);
     e.preventDefault();
   };
   private onPaste = (e: ClipboardEvent) => {
-    if (isInputLike(e.target) && !isToolIcon(e.target)) {
-      return;
+    // #686
+    const target = document.activeElement;
+    const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
+    if (
+      elementUnderCursor instanceof HTMLCanvasElement &&
+      !isWritableElement(target)
+    ) {
+      const data = parseClipboardEvent(e);
+      if (data.elements) {
+        this.addElementsFromPaste(data.elements);
+      } else if (data.text) {
+        const { x, y } = viewportCoordsToSceneCoords(
+          { clientX: cursorX, clientY: cursorY },
+          this.state,
+        );
+
+        const element = newTextElement(
+          newElement(
+            "text",
+            x,
+            y,
+            this.state.currentItemStrokeColor,
+            this.state.currentItemBackgroundColor,
+            this.state.currentItemFillStyle,
+            this.state.currentItemStrokeWidth,
+            this.state.currentItemRoughness,
+            this.state.currentItemOpacity,
+          ),
+          data.text,
+          this.state.currentItemFont,
+        );
+
+        element.isSelected = true;
+
+        elements = [...clearSelection(elements), element];
+        this.setState({});
+      }
+      e.preventDefault();
     }
-    const paste = e.clipboardData?.getData("text") || "";
-    this.addElementsFromPaste(paste);
-    e.preventDefault();
   };
 
   private onUnload = () => {
@@ -452,24 +477,14 @@ export class App extends React.Component<any, AppState> {
 
   private removeWheelEventListener: (() => void) | undefined;
 
-  private copyToClipboard = () => {
-    const text = JSON.stringify(
-      elements
-        .filter(element => element.isSelected)
-        .map(({ shape, ...el }) => el),
-    );
-    if ("clipboard" in navigator && "writeText" in navigator.clipboard) {
-      navigator.clipboard.writeText(text);
-    } else {
-      document.execCommand("copy");
-    }
+  private copyToAppClipboard = () => {
+    copyToAppClipboard(elements);
   };
 
   private pasteFromClipboard = () => {
-    if ("clipboard" in navigator && "readText" in navigator.clipboard) {
-      navigator.clipboard
-        .readText()
-        .then(text => this.addElementsFromPaste(text));
+    const data = getAppClipboard();
+    if (data.elements) {
+      this.addElementsFromPaste(data.elements);
     }
   };
 
@@ -809,7 +824,7 @@ export class App extends React.Component<any, AppState> {
                 options: [
                   navigator.clipboard && {
                     label: t("labels.copy"),
-                    action: this.copyToClipboard,
+                    action: this.copyToAppClipboard,
                   },
                   navigator.clipboard && {
                     label: t("labels.paste"),
@@ -1835,45 +1850,34 @@ export class App extends React.Component<any, AppState> {
     });
   };
 
-  private addElementsFromPaste = (paste: string) => {
-    let parsedElements;
-    try {
-      parsedElements = JSON.parse(paste);
-    } catch (e) {}
-    if (
-      Array.isArray(parsedElements) &&
-      parsedElements.length > 0 &&
-      parsedElements[0].type // need to implement a better check here...
-    ) {
-      elements = clearSelection(elements);
-
-      const [minX, minY, maxX, maxY] = getCommonBounds(parsedElements);
-
-      const elementsCenterX = distance(minX, maxX) / 2;
-      const elementsCenterY = distance(minY, maxY) / 2;
-
-      const dx =
-        cursorX -
-        this.state.scrollX -
-        CANVAS_WINDOW_OFFSET_LEFT -
-        elementsCenterX;
-      const dy =
-        cursorY -
-        this.state.scrollY -
-        CANVAS_WINDOW_OFFSET_TOP -
-        elementsCenterY;
-
-      elements = [
-        ...elements,
-        ...parsedElements.map(parsedElement => {
-          const duplicate = duplicateElement(parsedElement);
-          duplicate.x += dx - minX;
-          duplicate.y += dy - minY;
-          return duplicate;
-        }),
-      ];
-      this.setState({});
-    }
+  private addElementsFromPaste = (
+    clipboardElements: readonly ExcalidrawElement[],
+  ) => {
+    elements = clearSelection(elements);
+
+    const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
+
+    const elementsCenterX = distance(minX, maxX) / 2;
+    const elementsCenterY = distance(minY, maxY) / 2;
+
+    const dx =
+      cursorX -
+      this.state.scrollX -
+      CANVAS_WINDOW_OFFSET_LEFT -
+      elementsCenterX;
+    const dy =
+      cursorY - this.state.scrollY - CANVAS_WINDOW_OFFSET_TOP - elementsCenterY;
+
+    elements = [
+      ...elements,
+      ...clipboardElements.map(clipboardElements => {
+        const duplicate = duplicateElement(clipboardElements);
+        duplicate.x += dx - minX;
+        duplicate.y += dy - minY;
+        return duplicate;
+      }),
+    ];
+    this.setState({});
   };
 
   private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {

+ 16 - 17
src/scene/data.ts

@@ -11,6 +11,10 @@ import { getCommonBounds, normalizeDimensions } from "../element";
 
 import { Point } from "roughjs/bin/geometry";
 import { t } from "../i18n";
+import {
+  copyTextToSystemClipboard,
+  copyCanvasToClipboardAsPng,
+} from "../clipboard";
 
 const LOCAL_STORAGE_KEY = "excalidraw";
 const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes";
@@ -150,12 +154,16 @@ export async function exportToBackend(
       const url = new URL(window.location.href);
       url.searchParams.append("id", json.id);
 
-      await navigator.clipboard.writeText(url.toString());
-      window.alert(
-        t("alerts.copiedToClipboard", {
-          url: url.toString(),
-        }),
-      );
+      try {
+        await copyTextToSystemClipboard(url.toString());
+        window.alert(
+          t("alerts.copiedToClipboard", {
+            url: url.toString(),
+          }),
+        );
+      } catch (err) {
+        // TODO: link will be displayed for user to copy manually in later PR
+      }
     } else {
       window.alert(t("alerts.couldNotCreateShareableLink"));
     }
@@ -241,19 +249,10 @@ export async function exportCanvas(
       }
     });
   } else if (type === "clipboard") {
-    const errorMsg = t("alerts.couldNotCopyToClipboard");
     try {
-      tempCanvas.toBlob(async function(blob: any) {
-        try {
-          await navigator.clipboard.write([
-            new window.ClipboardItem({ "image/png": blob }),
-          ]);
-        } catch (err) {
-          window.alert(errorMsg);
-        }
-      });
+      copyCanvasToClipboardAsPng(tempCanvas);
     } catch (err) {
-      window.alert(errorMsg);
+      window.alert(t("alerts.couldNotCopyToClipboard"));
     }
   } else if (type === "backend") {
     const appState = getDefaultAppState();

+ 17 - 0
src/utils.ts

@@ -28,6 +28,7 @@ export function isInputLike(
   | HTMLInputElement
   | HTMLTextAreaElement
   | HTMLSelectElement
+  | HTMLBRElement
   | HTMLDivElement {
   return (
     (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
@@ -38,6 +39,22 @@ export function isInputLike(
   );
 }
 
+export function isWritableElement(
+  target: Element | EventTarget | null,
+): target is
+  | HTMLInputElement
+  | HTMLTextAreaElement
+  | HTMLBRElement
+  | HTMLDivElement {
+  return (
+    (target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
+    target instanceof HTMLBRElement || // newline in wysiwyg
+    target instanceof HTMLTextAreaElement ||
+    (target instanceof HTMLInputElement &&
+      (target.type === "text" || target.type === "number"))
+  );
+}
+
 // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
 export function measureText(text: string, font: string) {
   const line = document.createElement("div");