ソースを参照

System clipboard (#2117)

David Luzar 4 年 前
コミット
47dba05c91

+ 19 - 15
src/charts.ts

@@ -4,19 +4,23 @@ import { AppState } from "./types";
 import { t } from "./i18n";
 import { DEFAULT_VERTICAL_ALIGN } from "./constants";
 
-interface Spreadsheet {
+export interface Spreadsheet {
   yAxisLabel: string | null;
   labels: string[] | null;
   values: number[];
 }
 
+export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
+export const MALFORMED_SPREADSHEET = "MALFORMED_SPREADSHEET";
+export const VALID_SPREADSHEET = "VALID_SPREADSHEET";
+
 type ParseSpreadsheetResult =
   | {
-      type: "not a spreadsheet";
+      type: typeof NOT_SPREADSHEET;
     }
-  | { type: "spreadsheet"; spreadsheet: Spreadsheet }
+  | { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet }
   | {
-      type: "malformed spreadsheet";
+      type: typeof MALFORMED_SPREADSHEET;
       error: string;
     };
 
@@ -38,12 +42,12 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult {
   const numCols = cells[0].length;
 
   if (numCols > 2) {
-    return { type: "malformed spreadsheet", error: t("charts.tooManyColumns") };
+    return { type: MALFORMED_SPREADSHEET, error: t("charts.tooManyColumns") };
   }
 
   if (numCols === 1) {
     if (!isNumericColumn(cells, 0)) {
-      return { type: "not a spreadsheet" };
+      return { type: NOT_SPREADSHEET };
     }
 
     const hasHeader = tryParseNumber(cells[0][0]) === null;
@@ -52,11 +56,11 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult {
     );
 
     if (values.length < 2) {
-      return { type: "not a spreadsheet" };
+      return { type: NOT_SPREADSHEET };
     }
 
     return {
-      type: "spreadsheet",
+      type: VALID_SPREADSHEET,
       spreadsheet: {
         yAxisLabel: hasHeader ? cells[0][0] : null,
         labels: null,
@@ -69,7 +73,7 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult {
 
   if (!isNumericColumn(cells, valueColumnIndex)) {
     return {
-      type: "malformed spreadsheet",
+      type: MALFORMED_SPREADSHEET,
       error: t("charts.noNumericColumn"),
     };
   }
@@ -79,11 +83,11 @@ function tryParseCells(cells: string[][]): ParseSpreadsheetResult {
   const rows = hasHeader ? cells.slice(1) : cells;
 
   if (rows.length < 2) {
-    return { type: "not a spreadsheet" };
+    return { type: NOT_SPREADSHEET };
   }
 
   return {
-    type: "spreadsheet",
+    type: VALID_SPREADSHEET,
     spreadsheet: {
       yAxisLabel: hasHeader ? cells[0][valueColumnIndex] : null,
       labels: rows.map((row) => row[labelColumnIndex]),
@@ -114,7 +118,7 @@ export function tryParseSpreadsheet(text: string): ParseSpreadsheetResult {
     .map((line) => line.trim().split("\t"));
 
   if (lines.length === 0) {
-    return { type: "not a spreadsheet" };
+    return { type: NOT_SPREADSHEET };
   }
 
   const numColsFirstLine = lines[0].length;
@@ -123,13 +127,13 @@ export function tryParseSpreadsheet(text: string): ParseSpreadsheetResult {
   );
 
   if (!isASpreadsheet) {
-    return { type: "not a spreadsheet" };
+    return { type: NOT_SPREADSHEET };
   }
 
   const result = tryParseCells(lines);
-  if (result.type !== "spreadsheet") {
+  if (result.type !== VALID_SPREADSHEET) {
     const transposedResults = tryParseCells(transposeCells(lines));
-    if (transposedResults.type === "spreadsheet") {
+    if (transposedResults.type === VALID_SPREADSHEET) {
       return transposedResults;
     }
   }

+ 105 - 60
src/clipboard.ts

@@ -5,7 +5,20 @@ import {
 import { getSelectedElements } from "./scene";
 import { AppState } from "./types";
 import { SVG_EXPORT_TAG } from "./scene/export";
-import { tryParseSpreadsheet, renderSpreadsheet } from "./charts";
+import {
+  tryParseSpreadsheet,
+  Spreadsheet,
+  VALID_SPREADSHEET,
+  MALFORMED_SPREADSHEET,
+} from "./charts";
+
+const TYPE_ELEMENTS = "excalidraw/elements";
+
+type ElementsClipboard = {
+  type: typeof TYPE_ELEMENTS;
+  created: number;
+  elements: ExcalidrawElement[];
+};
 
 let CLIPBOARD = "";
 let PREFER_APP_CLIPBOARD = false;
@@ -22,86 +35,126 @@ export const probablySupportsClipboardBlob =
   "ClipboardItem" in window &&
   "toBlob" in HTMLCanvasElement.prototype;
 
-export const copyToAppClipboard = async (
+const isElementsClipboard = (contents: any): contents is ElementsClipboard => {
+  if (contents?.type === TYPE_ELEMENTS) {
+    return true;
+  }
+  return false;
+};
+
+export const copyToClipboard = async (
   elements: readonly NonDeletedExcalidrawElement[],
   appState: AppState,
 ) => {
-  CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
+  const contents: ElementsClipboard = {
+    type: TYPE_ELEMENTS,
+    created: Date.now(),
+    elements: getSelectedElements(elements, appState),
+  };
+  const json = JSON.stringify(contents);
+  CLIPBOARD = json;
   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 {
-    // 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
+    await copyTextToSystemClipboard(json);
+  } catch (err) {
     PREFER_APP_CLIPBOARD = true;
+    console.error(err);
   }
 };
 
-export const getAppClipboard = (): {
-  elements?: readonly ExcalidrawElement[];
-} => {
+const getAppClipboard = (): Partial<ElementsClipboard> => {
   if (!CLIPBOARD) {
     return {};
   }
 
   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 };
-    }
+    return JSON.parse(CLIPBOARD);
   } catch (error) {
     console.error(error);
+    return {};
   }
+};
 
-  return {};
+const parsePotentialSpreadsheet = (
+  text: string,
+): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
+  const result = tryParseSpreadsheet(text);
+  if (result.type === VALID_SPREADSHEET) {
+    return { spreadsheet: result.spreadsheet };
+  } else if (result.type === MALFORMED_SPREADSHEET) {
+    return { errorMessage: result.error };
+  }
+  return null;
 };
 
-export const getClipboardContent = async (
-  appState: AppState,
-  cursorX: number,
-  cursorY: number,
+/**
+ * Retrieves content from system clipboard (either from ClipboardEvent or
+ *  via async clipboard API if supported)
+ */
+const getSystemClipboard = async (
   event: ClipboardEvent | null,
-): Promise<{
-  text?: string;
-  elements?: readonly ExcalidrawElement[];
-  error?: string;
-}> => {
+): Promise<string> => {
   try {
     const text = event
       ? event.clipboardData?.getData("text/plain").trim()
       : probablySupportsClipboardReadText &&
         (await navigator.clipboard.readText());
 
-    if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) {
-      const result = tryParseSpreadsheet(text);
-      if (result.type === "spreadsheet") {
-        return {
-          elements: renderSpreadsheet(
-            appState,
-            result.spreadsheet,
-            cursorX,
-            cursorY,
-          ),
-        };
-      } else if (result.type === "malformed spreadsheet") {
-        return { error: result.error };
-      }
-      return { text };
-    }
-  } catch (error) {
-    console.error(error);
+    return text || "";
+  } catch {
+    return "";
   }
+};
 
-  return getAppClipboard();
+/**
+ * Attemps to parse clipboard. Prefers system clipboard.
+ */
+export const parseClipboard = async (
+  event: ClipboardEvent | null,
+): Promise<{
+  spreadsheet?: Spreadsheet;
+  elements?: readonly ExcalidrawElement[];
+  text?: string;
+  errorMessage?: string;
+}> => {
+  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)) {
+    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);
+  if (spreadsheetResult) {
+    return spreadsheetResult;
+  }
+
+  const appClipboardData = getAppClipboard();
+
+  try {
+    const systemClipboardData = JSON.parse(systemClipboard);
+    // system clipboard elements are newer than in-app clipboard
+    if (
+      isElementsClipboard(systemClipboardData) &&
+      (!appClipboardData?.created ||
+        appClipboardData.created < systemClipboardData.created)
+    ) {
+      return { elements: systemClipboardData.elements };
+    }
+    // in-app clipboard is newer than system clipboard
+    return appClipboardData;
+  } catch {
+    // system clipboard doesn't contain excalidraw elements → return plaintext
+    //  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
+      : { text: systemClipboard };
+  }
 };
 
 export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) =>
@@ -122,14 +175,6 @@ export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) =>
     }
   });
 
-export const copyCanvasToClipboardAsSvg = async (svgroot: SVGSVGElement) => {
-  try {
-    await navigator.clipboard.writeText(svgroot.outerHTML);
-  } catch (error) {
-    console.error(error);
-  }
-};
-
 export const copyTextToSystemClipboard = async (text: string | null) => {
   let copied = false;
   if (probablySupportsClipboardWriteText) {

+ 11 - 11
src/components/App.tsx

@@ -100,8 +100,8 @@ import { getDefaultAppState } from "../appState";
 import { t, getLanguage } from "../i18n";
 
 import {
-  copyToAppClipboard,
-  getClipboardContent,
+  copyToClipboard,
+  parseClipboard,
   probablySupportsClipboardBlob,
   probablySupportsClipboardWriteText,
 } from "../clipboard";
@@ -174,6 +174,7 @@ import {
   shouldEnableBindingForPointerEvent,
 } from "../element/binding";
 import { MaybeTransformHandleType } from "../element/transformHandles";
+import { renderSpreadsheet } from "../charts";
 
 /**
  * @param func handler taking at most single parameter (event).
@@ -872,7 +873,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
   });
 
   private copyAll = () => {
-    copyToAppClipboard(this.scene.getElements(), this.state);
+    copyToClipboard(this.scene.getElements(), this.state);
   };
 
   private copyToClipboardAsPng = () => {
@@ -960,14 +961,13 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       ) {
         return;
       }
-      const data = await getClipboardContent(
-        this.state,
-        cursorX,
-        cursorY,
-        event,
-      );
-      if (data.error) {
-        alert(data.error);
+      const data = await parseClipboard(event);
+      if (data.errorMessage) {
+        this.setState({ errorMessage: data.errorMessage });
+      } else if (data.spreadsheet) {
+        this.addElementsFromPasteOrLibrary(
+          renderSpreadsheet(this.state, data.spreadsheet, cursorX, cursorY),
+        );
       } else if (data.elements) {
         this.addElementsFromPasteOrLibrary(data.elements);
       } else if (data.text) {

+ 3 - 0
src/components/LayerUI.tsx

@@ -371,6 +371,9 @@ const LayerUI = ({
               onUsernameChange={onUsernameChange}
               onRoomCreate={onRoomCreate}
               onRoomDestroy={onRoomDestroy}
+              setErrorMessage={(message: string) =>
+                setAppState({ errorMessage: message })
+              }
             />
           </Stack.Row>
           <BackgroundPickerAndDarkModeToggle

+ 3 - 0
src/components/MobileMenu.tsx

@@ -100,6 +100,9 @@ export const MobileMenu = ({
                   onUsernameChange={onUsernameChange}
                   onRoomCreate={onRoomCreate}
                   onRoomDestroy={onRoomDestroy}
+                  setErrorMessage={(message: string) =>
+                    setAppState({ errorMessage: message })
+                  }
                 />
                 <BackgroundPickerAndDarkModeToggle
                   actionManager={actionManager}

+ 11 - 2
src/components/RoomDialog.tsx

@@ -16,6 +16,7 @@ const RoomModal = ({
   onRoomCreate,
   onRoomDestroy,
   onPressingEnter,
+  setErrorMessage,
 }: {
   activeRoomLink: string;
   username: string;
@@ -23,11 +24,16 @@ const RoomModal = ({
   onRoomCreate: () => void;
   onRoomDestroy: () => void;
   onPressingEnter: () => void;
+  setErrorMessage: (message: string) => void;
 }) => {
   const roomLinkInput = useRef<HTMLInputElement>(null);
 
-  const copyRoomLink = () => {
-    copyTextToSystemClipboard(activeRoomLink);
+  const copyRoomLink = async () => {
+    try {
+      await copyTextToSystemClipboard(activeRoomLink);
+    } catch (error) {
+      setErrorMessage(error.message);
+    }
     if (roomLinkInput.current) {
       roomLinkInput.current.select();
     }
@@ -127,6 +133,7 @@ export const RoomDialog = ({
   onUsernameChange,
   onRoomCreate,
   onRoomDestroy,
+  setErrorMessage,
 }: {
   isCollaborating: AppState["isCollaborating"];
   collaboratorCount: number;
@@ -134,6 +141,7 @@ export const RoomDialog = ({
   onUsernameChange: (username: string) => void;
   onRoomCreate: () => void;
   onRoomDestroy: () => void;
+  setErrorMessage: (message: string) => void;
 }) => {
   const [modalIsShown, setModalIsShown] = useState(false);
   const [activeRoomLink, setActiveRoomLink] = useState("");
@@ -182,6 +190,7 @@ export const RoomDialog = ({
             onRoomCreate={onRoomCreate}
             onRoomDestroy={onRoomDestroy}
             onPressingEnter={handleClose}
+            setErrorMessage={setErrorMessage}
           />
         </Dialog>
       )}

+ 2 - 2
src/data/index.ts

@@ -12,7 +12,7 @@ import { fileSave } from "browser-nativefs";
 import { t } from "../i18n";
 import {
   copyCanvasToClipboardAsPng,
-  copyCanvasToClipboardAsSvg,
+  copyTextToSystemClipboard,
 } from "../clipboard";
 import { serializeAsJSON } from "./json";
 
@@ -317,7 +317,7 @@ export const exportCanvas = async (
       });
       return;
     } else if (type === "clipboard-svg") {
-      copyCanvasToClipboardAsSvg(tempSvg);
+      copyTextToSystemClipboard(tempSvg.outerHTML);
       return;
     }
   }