Browse Source

feat: show copy-as-png export button on firefox and show steps how to enable it (#6125)

* feat: hide copy-as-png shortcut from help dialog if not supported

* fix: support firefox if clipboard.write supported

* show shrotcut in firefox and instead show error message how to enable the flag support

* widen to TypeError because minification

* show copy-as-png on firefox even if it will throw
David Luzar 2 years ago
parent
commit
d2b698093c

+ 2 - 1
src/actions/actionHistory.tsx

@@ -5,10 +5,11 @@ import { t } from "../i18n";
 import History, { HistoryEntry } from "../history";
 import { ExcalidrawElement } from "../element/types";
 import { AppState } from "../types";
-import { isWindows, KEYS } from "../keys";
+import { KEYS } from "../keys";
 import { newElementWith } from "../element/mutateElement";
 import { fixBindingsAfterDeletion } from "../element/binding";
 import { arrayToMap } from "../utils";
+import { isWindows } from "../constants";
 
 const writeData = (
   prevElements: readonly ExcalidrawElement[],

+ 2 - 1
src/actions/actionZindex.tsx

@@ -5,7 +5,7 @@ import {
   moveAllLeft,
   moveAllRight,
 } from "../zindex";
-import { KEYS, isDarwin, CODES } from "../keys";
+import { KEYS, CODES } from "../keys";
 import { t } from "../i18n";
 import { getShortcutKey } from "../utils";
 import { register } from "./register";
@@ -15,6 +15,7 @@ import {
   SendBackwardIcon,
   SendToBackIcon,
 } from "../components/icons";
+import { isDarwin } from "../constants";
 
 export const actionSendBackward = register({
   name: "sendBackward",

+ 1 - 1
src/actions/shortcuts.ts

@@ -1,5 +1,5 @@
+import { isDarwin } from "../constants";
 import { t } from "../i18n";
-import { isDarwin } from "../keys";
 import { getShortcutKey } from "../utils";
 import { ActionName } from "./types";
 

+ 4 - 5
src/clipboard.ts

@@ -180,16 +180,16 @@ export const parseClipboard = async (
 };
 
 export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
-  let promise;
   try {
     // in Safari so far we need to construct the ClipboardItem synchronously
     // (i.e. in the same tick) otherwise browser will complain for lack of
     // user intent. Using a Promise ClipboardItem constructor solves this.
     // https://bugs.webkit.org/show_bug.cgi?id=222262
     //
-    // not await so that we can detect whether the thrown error likely relates
-    // to a lack of support for the Promise ClipboardItem constructor
-    promise = navigator.clipboard.write([
+    // Note that Firefox (and potentially others) seems to support Promise
+    // ClipboardItem constructor, but throws on an unrelated MIME type error.
+    // So we need to await this and fallback to awaiting the blob if applicable.
+    await navigator.clipboard.write([
       new window.ClipboardItem({
         [MIME_TYPES.png]: blob,
       }),
@@ -207,7 +207,6 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
       throw error;
     }
   }
-  await promise;
 };
 
 export const copyTextToSystemClipboard = async (text: string | null) => {

+ 1 - 1
src/components/App.tsx

@@ -57,6 +57,7 @@ import {
   EVENT,
   GRID_SIZE,
   IMAGE_RENDER_TIMEOUT,
+  isAndroid,
   LINE_CONFIRM_THRESHOLD,
   MAX_ALLOWED_FILE_BYTES,
   MIME_TYPES,
@@ -166,7 +167,6 @@ import {
   shouldRotateWithDiscreteAngle,
   isArrowKey,
   KEYS,
-  isAndroid,
 } from "../keys";
 import { distance2d, getGridPoint, isPathALoop } from "../math";
 import { renderScene } from "../renderer/renderScene";

+ 11 - 5
src/components/HelpDialog.tsx

@@ -1,10 +1,12 @@
 import React from "react";
 import { t } from "../i18n";
-import { isDarwin, isWindows, KEYS } from "../keys";
+import { KEYS } from "../keys";
 import { Dialog } from "./Dialog";
 import { getShortcutKey } from "../utils";
 import "./HelpDialog.scss";
 import { ExternalLinkIcon } from "./icons";
+import { probablySupportsClipboardBlob } from "../clipboard";
+import { isDarwin, isFirefox, isWindows } from "../constants";
 
 const Header = () => (
   <div className="HelpDialog__header">
@@ -304,10 +306,14 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
               label={t("labels.pasteAsPlaintext")}
               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
             />
-            <Shortcut
-              label={t("labels.copyAsPng")}
-              shortcuts={[getShortcutKey("Shift+Alt+C")]}
-            />
+            {/* firefox supports clipboard API under a flag, so we'll
+                show users what they can do in the error message */}
+            {(probablySupportsClipboardBlob || isFirefox) && (
+              <Shortcut
+                label={t("labels.copyAsPng")}
+                shortcuts={[getShortcutKey("Shift+Alt+C")]}
+              />
+            )}
             <Shortcut
               label={t("labels.copyStyles")}
               shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}

+ 4 - 2
src/components/ImageExportDialog.tsx

@@ -12,7 +12,7 @@ import Stack from "./Stack";
 import "./ExportDialog.scss";
 import OpenColor from "open-color";
 import { CheckboxItem } from "./CheckboxItem";
-import { DEFAULT_EXPORT_PADDING } from "../constants";
+import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
 import { nativeFileSystemSupported } from "../data/filesystem";
 import { ActionManager } from "../actions/manager";
 
@@ -190,7 +190,9 @@ const ImageExportModal = ({
         >
           SVG
         </ExportButton>
-        {probablySupportsClipboardBlob && (
+        {/* firefox supports clipboard API under a flag,
+            so let's throw and tell people what they can do */}
+        {(probablySupportsClipboardBlob || isFirefox) && (
           <ExportButton
             title={t("buttons.copyPngToClipboard")}
             onClick={() => onExportToClipboard(exportedElements)}

+ 8 - 0
src/constants.ts

@@ -2,6 +2,14 @@ import cssVariables from "./css/variables.module.scss";
 import { AppProps } from "./types";
 import { FontFamilyValues } from "./element/types";
 
+export const isDarwin = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
+export const isWindows = /^Win/.test(navigator.platform);
+export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
+export const isFirefox =
+  "netscape" in window &&
+  navigator.userAgent.indexOf("rv:") > 1 &&
+  navigator.userAgent.indexOf("Gecko") > 1;
+
 export const APP_NAME = "Excalidraw";
 
 export const DRAGGING_THRESHOLD = 10; // px

+ 13 - 2
src/data/index.ts

@@ -2,7 +2,7 @@ import {
   copyBlobToClipboardAsPng,
   copyTextToSystemClipboard,
 } from "../clipboard";
-import { DEFAULT_EXPORT_PADDING, MIME_TYPES } from "../constants";
+import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { exportToCanvas, exportToSvg } from "../scene/export";
@@ -97,10 +97,21 @@ export const exportCanvas = async (
       const blob = canvasToBlob(tempCanvas);
       await copyBlobToClipboardAsPng(blob);
     } catch (error: any) {
+      console.warn(error);
       if (error.name === "CANVAS_POSSIBLY_TOO_BIG") {
         throw error;
       }
-      throw new Error(t("alerts.couldNotCopyToClipboard"));
+      // TypeError *probably* suggests ClipboardItem not defined, which
+      // people on Firefox can enable through a flag, so let's tell them.
+      if (isFirefox && error.name === "TypeError") {
+        throw new Error(
+          `${t("alerts.couldNotCopyToClipboard")}\n\n${t(
+            "hints.firefox_clipboard_write",
+          )}`,
+        );
+      } else {
+        throw new Error(t("alerts.couldNotCopyToClipboard"));
+      }
     } finally {
       tempCanvas.remove();
     }

+ 1 - 3
src/keys.ts

@@ -1,6 +1,4 @@
-export const isDarwin = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform);
-export const isWindows = /^Win/.test(window.navigator.platform);
-export const isAndroid = /\b(android)\b/i.test(navigator.userAgent);
+import { isDarwin } from "./constants";
 
 export const CODES = {
   EQUAL: "Equal",

+ 2 - 1
src/locales/en.json

@@ -246,7 +246,8 @@
     "publishLibrary": "Publish your own library",
     "bindTextToElement": "Press enter to add text",
     "deepBoxSelect": "Hold CtrlOrCmd to deep select, and to prevent dragging",
-    "eraserRevert": "Hold Alt to revert the elements marked for deletion"
+    "eraserRevert": "Hold Alt to revert the elements marked for deletion",
+    "firefox_clipboard_write": "This feature can likely be enabled by setting the \"dom.events.asyncClipboard.clipboardItem\" flag to \"true\". To change the browser flags in Firefox, visit the \"about:config\" page."
   },
   "canvasError": {
     "cannotShowPreview": "Cannot show preview",

+ 1 - 1
src/utils.ts

@@ -6,6 +6,7 @@ import {
   DEFAULT_VERSION,
   EVENT,
   FONT_FAMILY,
+  isDarwin,
   MIME_TYPES,
   THEME,
   WINDOWS_EMOJI_FALLBACK_FONT,
@@ -13,7 +14,6 @@ import {
 import { FontFamilyValues, FontString } from "./element/types";
 import { AppState, DataURL, LastActiveToolBeforeEraser, Zoom } from "./types";
 import { unstable_batchedUpdates } from "react-dom";
-import { isDarwin } from "./keys";
 import { SHAPES } from "./shapes";
 import React from "react";