ソースを参照

Improve pasting (#723)

* switch to selection tool on paste

* align pasting via contextMenu with pasting from event

* ensure only plaintext can be pasted

* fix findShapeByKey regression

* simplify wysiwyg pasting

* improve wysiwyg blurriness
David Luzar 5 年 前
コミット
88eacc9da7
5 ファイル変更119 行追加82 行削除
  1. 19 9
      src/clipboard.ts
  2. 29 0
      src/element/textWysiwyg.tsx
  3. 63 63
      src/index.tsx
  4. 6 9
      src/shapes.tsx
  5. 2 1
      src/types.ts

+ 19 - 9
src/clipboard.ts

@@ -3,6 +3,9 @@ import { ExcalidrawElement } from "./element/types";
 let CLIPBOARD = "";
 let PREFER_APP_CLIPBOARD = false;
 
+export const probablySupportsClipboardReadText =
+  "clipboard" in navigator && "readText" in navigator.clipboard;
+
 export const probablySupportsClipboardWriteText =
   "clipboard" in navigator && "writeText" in navigator.clipboard;
 
@@ -47,26 +50,33 @@ export function getAppClipboard(): {
     ) {
       return { elements: clipboardElements };
     }
-  } catch (err) {}
+  } catch (err) {
+    console.error(err);
+  }
 
   return {};
 }
 
-export function parseClipboardEvent(
-  e: ClipboardEvent,
-): {
+export async function getClipboardContent(
+  e: ClipboardEvent | null,
+): Promise<{
   text?: string;
   elements?: readonly ExcalidrawElement[];
-} {
+}> {
   try {
-    const text = e.clipboardData?.getData("text/plain").trim();
+    const text = e
+      ? e.clipboardData?.getData("text/plain").trim()
+      : probablySupportsClipboardReadText &&
+        (await navigator.clipboard.readText());
+
     if (text && !PREFER_APP_CLIPBOARD) {
       return { text };
     }
-    return getAppClipboard();
-  } catch (e) {}
+  } catch (err) {
+    console.error(err);
+  }
 
-  return {};
+  return getAppClipboard();
 }
 
 export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {

+ 29 - 0
src/element/textWysiwyg.tsx

@@ -53,8 +53,36 @@ export function textWysiwyg({
     outline: "1px solid transparent",
     whiteSpace: "nowrap",
     minHeight: "1em",
+    backfaceVisibility: "hidden",
   });
 
+  editable.onpaste = ev => {
+    try {
+      const selection = window.getSelection();
+      if (!selection?.rangeCount) {
+        return;
+      }
+      selection.deleteFromDocument();
+
+      const text = ev.clipboardData!.getData("text").replace(/\r\n?/g, "\n");
+
+      const span = document.createElement("span");
+      span.innerText = text;
+      const range = selection.getRangeAt(0);
+      range.insertNode(span);
+
+      // deselect
+      window.getSelection()!.removeAllRanges();
+      range.setStart(span, span.childNodes.length);
+      range.setEnd(span, span.childNodes.length);
+      selection.addRange(range);
+
+      ev.preventDefault();
+    } catch (err) {
+      console.error(err);
+    }
+  };
+
   editable.onkeydown = ev => {
     if (ev.key === KEYS.ESCAPE) {
       ev.preventDefault();
@@ -92,6 +120,7 @@ export function textWysiwyg({
   function cleanup() {
     editable.onblur = null;
     editable.onkeydown = null;
+    editable.onpaste = null;
     window.removeEventListener("wheel", stopEvent, true);
     document.body.removeChild(editable);
   }

+ 63 - 63
src/index.tsx

@@ -46,6 +46,7 @@ import { ExcalidrawElement } from "./element/types";
 import {
   isWritableElement,
   isInputLike,
+  isToolIcon,
   debounce,
   capitalizeString,
   distance,
@@ -100,11 +101,7 @@ import { t, languages, setLanguage, getLanguage } from "./i18n";
 import { StoredScenesList } from "./components/StoredScenesList";
 import { HintViewer } from "./components/HintViewer";
 
-import {
-  getAppClipboard,
-  copyToAppClipboard,
-  parseClipboardEvent,
-} from "./clipboard";
+import { copyToAppClipboard, getClipboardContent } from "./clipboard";
 
 let { elements } = createScene();
 const { history } = createHistory();
@@ -474,48 +471,6 @@ export class App extends React.Component<any, AppState> {
     copyToAppClipboard(elements);
     e.preventDefault();
   };
-  private onPaste = (e: ClipboardEvent) => {
-    // #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];
-        history.resumeRecording();
-        this.setState({});
-      }
-      e.preventDefault();
-    }
-  };
 
   private onUnload = () => {
     isHoldingSpace = false;
@@ -551,7 +506,7 @@ export class App extends React.Component<any, AppState> {
 
   public async componentDidMount() {
     document.addEventListener("copy", this.onCopy);
-    document.addEventListener("paste", this.onPaste);
+    document.addEventListener("paste", this.pasteFromClipboard);
     document.addEventListener("cut", this.onCut);
 
     document.addEventListener("keydown", this.onKeyDown, false);
@@ -583,7 +538,7 @@ export class App extends React.Component<any, AppState> {
 
   public componentWillUnmount() {
     document.removeEventListener("copy", this.onCopy);
-    document.removeEventListener("paste", this.onPaste);
+    document.removeEventListener("paste", this.pasteFromClipboard);
     document.removeEventListener("cut", this.onCut);
 
     document.removeEventListener("keydown", this.onKeyDown, false);
@@ -657,14 +612,7 @@ export class App extends React.Component<any, AppState> {
       !event.metaKey &&
       this.state.draggingElement === null
     ) {
-      if (!isHoldingSpace) {
-        setCursorForShape(shape);
-      }
-      if (document.activeElement instanceof HTMLElement) {
-        document.activeElement.blur();
-      }
-      elements = clearSelection(elements);
-      this.setState({ elementType: shape });
+      this.selectShapeTool(shape);
       // Undo action
     } else if (event[KEYS.META] && /z/i.test(event.key)) {
       event.preventDefault();
@@ -721,13 +669,65 @@ export class App extends React.Component<any, AppState> {
     copyToAppClipboard(elements);
   };
 
-  private pasteFromClipboard = () => {
-    const data = getAppClipboard();
-    if (data.elements) {
-      this.addElementsFromPaste(data.elements);
+  private pasteFromClipboard = async (e: ClipboardEvent | null) => {
+    // #686
+    const target = document.activeElement;
+    const elementUnderCursor = document.elementFromPoint(cursorX, cursorY);
+    if (
+      // if no ClipboardEvent supplied, assume we're pasting via contextMenu
+      //  thus these checks don't make sense
+      !e ||
+      (elementUnderCursor instanceof HTMLCanvasElement &&
+        !isWritableElement(target))
+    ) {
+      const data = await getClipboardContent(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];
+        history.resumeRecording();
+      }
+      this.selectShapeTool("selection");
+      e?.preventDefault();
     }
   };
 
+  private selectShapeTool(elementType: AppState["elementType"]) {
+    if (!isHoldingSpace) {
+      setCursorForShape(elementType);
+    }
+    if (isToolIcon(document.activeElement)) {
+      document.activeElement.blur();
+    }
+    if (elementType !== "selection") {
+      elements = clearSelection(elements);
+    }
+    this.setState({ elementType });
+  }
+
   setAppState = (obj: any) => {
     this.setState(obj);
   };
@@ -800,7 +800,7 @@ export class App extends React.Component<any, AppState> {
                   options: [
                     navigator.clipboard && {
                       label: t("labels.paste"),
-                      action: () => this.pasteFromClipboard(),
+                      action: () => this.pasteFromClipboard(null),
                     },
                     ...this.actionManager.getContextMenuItems(action =>
                       this.canvasOnlyActions.includes(action),
@@ -826,7 +826,7 @@ export class App extends React.Component<any, AppState> {
                   },
                   navigator.clipboard && {
                     label: t("labels.paste"),
-                    action: () => this.pasteFromClipboard(),
+                    action: () => this.pasteFromClipboard(null),
                   },
                   ...this.actionManager.getContextMenuItems(
                     action => !this.canvasOnlyActions.includes(action),

+ 6 - 9
src/shapes.tsx

@@ -65,7 +65,7 @@ export const SHAPES = [
     ),
     value: "text",
   },
-];
+] as const;
 
 export const shapesShortcutKeys = SHAPES.map((shape, index) => [
   shape.value[0],
@@ -73,12 +73,9 @@ export const shapesShortcutKeys = SHAPES.map((shape, index) => [
 ]).flat(1);
 
 export function findShapeByKey(key: string) {
-  const defaultElement = "selection";
-  return SHAPES.reduce((element, shape, index) => {
-    if (shape.value[0] !== key && key !== (index + 1).toString()) {
-      return element;
-    }
-
-    return shape.value;
-  }, defaultElement);
+  return (
+    SHAPES.find((shape, index) => {
+      return shape.value[0] === key || key === (index + 1).toString();
+    })?.value || "selection"
+  );
 }

+ 2 - 1
src/types.ts

@@ -1,4 +1,5 @@
 import { ExcalidrawElement } from "./element/types";
+import { SHAPES } from "./shapes";
 
 export type AppState = {
   draggingElement: ExcalidrawElement | null;
@@ -8,7 +9,7 @@ export type AppState = {
   // element being edited, but not necessarily added to elements array yet
   //  (e.g. text element when typing into the input)
   editingElement: ExcalidrawElement | null;
-  elementType: string;
+  elementType: typeof SHAPES[number]["value"];
   elementLocked: boolean;
   exportBackground: boolean;
   currentItemStrokeColor: string;