瀏覽代碼

feat: Add onExportToBackend prop so host can handle it (#2612)

Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi 4 年之前
父節點
當前提交
325d1bec91

+ 1 - 1
scripts/changelog-check.js

@@ -8,7 +8,7 @@ const changeLogCheck = () => {
         process.exit(1);
       }
 
-      if (!stdout || stdout.includes("packages/excalidraw/CHANGELOG.MD")) {
+      if (!stdout || stdout.includes("packages/excalidraw/CHANGELOG.md")) {
         process.exit(0);
       }
 

+ 2 - 1
src/components/App.tsx

@@ -345,7 +345,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
       offsetLeft,
     } = this.state;
 
-    const { onCollabButtonClick } = this.props;
+    const { onCollabButtonClick, onExportToBackend } = this.props;
     const canvasScale = window.devicePixelRatio;
 
     const canvasWidth = canvasDOMWidth * canvasScale;
@@ -384,6 +384,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
           toggleZenMode={this.toggleZenMode}
           lng={getLanguage().lng}
           isCollaborating={this.props.isCollaborating || false}
+          onExportToBackend={onExportToBackend}
         />
         {this.state.showStats && (
           <Stats

+ 11 - 9
src/components/ExportDialog.tsx

@@ -67,7 +67,7 @@ const ExportModal = ({
   onExportToPng: ExportCB;
   onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
-  onExportToBackend: ExportCB;
+  onExportToBackend?: ExportCB;
   onCloseRequest: () => void;
 }) => {
   const someElementIsSelected = isSomeElementSelected(elements, appState);
@@ -155,13 +155,15 @@ const ExportModal = ({
                 onClick={() => onExportToClipboard(exportedElements, scale)}
               />
             )}
-            <ToolButton
-              type="button"
-              icon={link}
-              title={t("buttons.getShareableLink")}
-              aria-label={t("buttons.getShareableLink")}
-              onClick={() => onExportToBackend(exportedElements)}
-            />
+            {onExportToBackend && (
+              <ToolButton
+                type="button"
+                icon={link}
+                title={t("buttons.getShareableLink")}
+                aria-label={t("buttons.getShareableLink")}
+                onClick={() => onExportToBackend(exportedElements)}
+              />
+            )}
           </Stack.Row>
           <div className="ExportDialog__name">
             {actionManager.renderAction("changeProjectName")}
@@ -235,7 +237,7 @@ export const ExportDialog = ({
   onExportToPng: ExportCB;
   onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
-  onExportToBackend: ExportCB;
+  onExportToBackend?: ExportCB;
 }) => {
   const [modalIsShown, setModalIsShown] = useState(false);
   const triggerButton = useRef<HTMLButtonElement>(null);

+ 14 - 21
src/components/LayerUI.tsx

@@ -65,6 +65,11 @@ interface LayerUIProps {
   toggleZenMode: () => void;
   lng: string;
   isCollaborating: boolean;
+  onExportToBackend?: (
+    exportedElements: readonly NonDeletedExcalidrawElement[],
+    appState: AppState,
+    canvas: HTMLCanvasElement | null,
+  ) => void;
 }
 
 const useOnClickOutside = (
@@ -317,6 +322,7 @@ const LayerUI = ({
   zenModeEnabled,
   toggleZenMode,
   isCollaborating,
+  onExportToBackend,
 }: LayerUIProps) => {
   const isMobile = useIsMobile();
 
@@ -358,6 +364,7 @@ const LayerUI = ({
           });
       }
     };
+
     return (
       <ExportDialog
         elements={elements}
@@ -366,28 +373,14 @@ const LayerUI = ({
         onExportToPng={createExporter("png")}
         onExportToSvg={createExporter("svg")}
         onExportToClipboard={createExporter("clipboard")}
-        onExportToBackend={async (exportedElements) => {
-          if (canvas) {
-            try {
-              await exportCanvas(
-                "backend",
-                exportedElements,
-                {
-                  ...appState,
-                  selectedElementIds: {},
-                },
-                canvas,
-                appState,
-              );
-            } catch (error) {
-              if (error.name !== "AbortError") {
-                const { width, height } = canvas;
-                console.error(error, { width, height });
-                setAppState({ errorMessage: error.message });
+        onExportToBackend={
+          onExportToBackend
+            ? (elements) => {
+                onExportToBackend &&
+                  onExportToBackend(elements, appState, canvas);
               }
-            }
-          }
-        }}
+            : undefined
+        }
       />
     );
   };

+ 1 - 71
src/data/index.ts

@@ -1,14 +1,10 @@
 import { fileSave } from "browser-nativefs";
 import { EVENT_IO, trackEvent } from "../analytics";
-import { getDefaultAppState } from "../appState";
 import {
   copyCanvasToClipboardAsPng,
   copyTextToSystemClipboard,
 } from "../clipboard";
-import {
-  ExcalidrawElement,
-  NonDeletedExcalidrawElement,
-} from "../element/types";
+import { NonDeletedExcalidrawElement } from "../element/types";
 import { t } from "../i18n";
 import { exportToCanvas, exportToSvg } from "../scene/export";
 import { ExportType } from "../scene/types";
@@ -19,65 +15,6 @@ import { serializeAsJSON } from "./json";
 export { loadFromBlob } from "./blob";
 export { loadFromJSON, saveAsJSON } from "./json";
 
-const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
-
-export const exportToBackend = async (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-) => {
-  const json = serializeAsJSON(elements, appState);
-  const encoded = new TextEncoder().encode(json);
-
-  const key = await window.crypto.subtle.generateKey(
-    {
-      name: "AES-GCM",
-      length: 128,
-    },
-    true, // extractable
-    ["encrypt", "decrypt"],
-  );
-  // The iv is set to 0. We are never going to reuse the same key so we don't
-  // need to have an iv. (I hope that's correct...)
-  const iv = new Uint8Array(12);
-  // We use symmetric encryption. AES-GCM is the recommended algorithm and
-  // includes checks that the ciphertext has not been modified by an attacker.
-  const encrypted = await window.crypto.subtle.encrypt(
-    {
-      name: "AES-GCM",
-      iv,
-    },
-    key,
-    encoded,
-  );
-  // We use jwk encoding to be able to extract just the base64 encoded key.
-  // We will hardcode the rest of the attributes when importing back the key.
-  const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
-
-  try {
-    const response = await fetch(BACKEND_V2_POST, {
-      method: "POST",
-      body: encrypted,
-    });
-    const json = await response.json();
-    if (json.id) {
-      const url = new URL(window.location.href);
-      // We need to store the key (and less importantly the id) as hash instead
-      // of queryParam in order to never send it to the server
-      url.hash = `json=${json.id},${exportedKey.k!}`;
-      const urlString = url.toString();
-      window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
-      trackEvent(EVENT_IO, "export", "backend");
-    } else if (json.error_class === "RequestTooLargeError") {
-      window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
-    } else {
-      window.alert(t("alerts.couldNotCreateShareableLink"));
-    }
-  } catch (error) {
-    console.error(error);
-    window.alert(t("alerts.couldNotCreateShareableLink"));
-  }
-};
-
 export const exportCanvas = async (
   type: ExportType,
   elements: readonly NonDeletedExcalidrawElement[],
@@ -169,13 +106,6 @@ export const exportCanvas = async (
       }
       throw new Error(t("alerts.couldNotCopyToClipboard"));
     }
-  } else if (type === "backend") {
-    exportToBackend(elements, {
-      ...appState,
-      viewBackgroundColor: exportBackground
-        ? appState.viewBackgroundColor
-        : getDefaultAppState().viewBackgroundColor,
-    });
   }
 
   // clean up the DOM

+ 60 - 1
src/excalidraw-app/data/index.ts

@@ -3,12 +3,14 @@ import { ExcalidrawElement } from "../../element/types";
 import { AppState } from "../../types";
 import { ImportedDataState } from "../../data/types";
 import { restore } from "../../data/restore";
-import { EVENT_ACTION, trackEvent } from "../../analytics";
+import { EVENT_ACTION, EVENT_IO, trackEvent } from "../../analytics";
+import { serializeAsJSON } from "../../data/json";
 
 const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
 
 const BACKEND_GET = process.env.REACT_APP_BACKEND_V1_GET_URL;
 const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
+const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
 
 const generateRandomID = async () => {
   const arr = new Uint8Array(10);
@@ -228,3 +230,60 @@ export const loadScene = async (
     commitToHistory: false,
   };
 };
+
+export const exportToBackend = async (
+  elements: readonly ExcalidrawElement[],
+  appState: AppState,
+) => {
+  const json = serializeAsJSON(elements, appState);
+  const encoded = new TextEncoder().encode(json);
+
+  const key = await window.crypto.subtle.generateKey(
+    {
+      name: "AES-GCM",
+      length: 128,
+    },
+    true, // extractable
+    ["encrypt", "decrypt"],
+  );
+  // The iv is set to 0. We are never going to reuse the same key so we don't
+  // need to have an iv. (I hope that's correct...)
+  const iv = new Uint8Array(12);
+  // We use symmetric encryption. AES-GCM is the recommended algorithm and
+  // includes checks that the ciphertext has not been modified by an attacker.
+  const encrypted = await window.crypto.subtle.encrypt(
+    {
+      name: "AES-GCM",
+      iv,
+    },
+    key,
+    encoded,
+  );
+  // We use jwk encoding to be able to extract just the base64 encoded key.
+  // We will hardcode the rest of the attributes when importing back the key.
+  const exportedKey = await window.crypto.subtle.exportKey("jwk", key);
+
+  try {
+    const response = await fetch(BACKEND_V2_POST, {
+      method: "POST",
+      body: encrypted,
+    });
+    const json = await response.json();
+    if (json.id) {
+      const url = new URL(window.location.href);
+      // We need to store the key (and less importantly the id) as hash instead
+      // of queryParam in order to never send it to the server
+      url.hash = `json=${json.id},${exportedKey.k!}`;
+      const urlString = url.toString();
+      window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
+      trackEvent(EVENT_IO, "export", "backend");
+    } else if (json.error_class === "RequestTooLargeError") {
+      window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
+    } else {
+      window.alert(t("alerts.couldNotCreateShareableLink"));
+    }
+  } catch (error) {
+    console.error(error);
+    window.alert(t("alerts.couldNotCreateShareableLink"));
+  }
+};

+ 53 - 13
src/excalidraw-app/index.tsx

@@ -13,16 +13,21 @@ import { ImportedDataState } from "../data/types";
 import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
 import { TopErrorBoundary } from "../components/TopErrorBoundary";
 import { t } from "../i18n";
-import { loadScene } from "./data";
+import { exportToBackend, loadScene } from "./data";
 import { getCollaborationLinkData } from "./data";
 import { EVENT } from "../constants";
 import { loadFromFirebase } from "./data/firebase";
 import { ExcalidrawImperativeAPI } from "../components/App";
 import { debounce, ResolvablePromise, resolvablePromise } from "../utils";
 import { AppState, ExcalidrawAPIRefValue } from "../types";
-import { ExcalidrawElement } from "../element/types";
+import {
+  ExcalidrawElement,
+  NonDeletedExcalidrawElement,
+} from "../element/types";
 import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
 import { EVENT_LOAD, EVENT_SHARE, trackEvent } from "../analytics";
+import { ErrorDialog } from "../components/ErrorDialog";
+import { getDefaultAppState } from "../appState";
 
 const excalidrawRef: React.MutableRefObject<
   MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
@@ -178,6 +183,7 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
     width: window.innerWidth,
     height: window.innerHeight,
   });
+  const [errorMessage, setErrorMessage] = useState("");
 
   useLayoutEffect(() => {
     const onResize = () => {
@@ -260,18 +266,52 @@ function ExcalidrawWrapper(props: { collab: CollabAPI }) {
     }
   };
 
+  const onExportToBackend = async (
+    exportedElements: readonly NonDeletedExcalidrawElement[],
+    appState: AppState,
+    canvas: HTMLCanvasElement | null,
+  ) => {
+    if (exportedElements.length === 0) {
+      return window.alert(t("alerts.cannotExportEmptyCanvas"));
+    }
+    if (canvas) {
+      try {
+        await exportToBackend(exportedElements, {
+          ...appState,
+          viewBackgroundColor: appState.exportBackground
+            ? appState.viewBackgroundColor
+            : getDefaultAppState().viewBackgroundColor,
+        });
+      } catch (error) {
+        if (error.name !== "AbortError") {
+          const { width, height } = canvas;
+          console.error(error, { width, height });
+          setErrorMessage(error.message);
+        }
+      }
+    }
+  };
   return (
-    <Excalidraw
-      ref={excalidrawRef}
-      onChange={onChange}
-      width={dimensions.width}
-      height={dimensions.height}
-      initialData={initialStatePromiseRef.current.promise}
-      user={{ name: collab.username }}
-      onCollabButtonClick={collab.onCollabButtonClick}
-      isCollaborating={collab.isCollaborating}
-      onPointerUpdate={collab.onPointerUpdate}
-    />
+    <>
+      <Excalidraw
+        ref={excalidrawRef}
+        onChange={onChange}
+        width={dimensions.width}
+        height={dimensions.height}
+        initialData={initialStatePromiseRef.current.promise}
+        user={{ name: collab.username }}
+        onCollabButtonClick={collab.onCollabButtonClick}
+        isCollaborating={collab.isCollaborating}
+        onPointerUpdate={collab.onPointerUpdate}
+        onExportToBackend={onExportToBackend}
+      />
+      {errorMessage && (
+        <ErrorDialog
+          message={errorMessage}
+          onClose={() => setErrorMessage("")}
+        />
+      )}
+    </>
   );
 }
 

+ 2 - 0
src/packages/excalidraw/CHANGELOG.MD → src/packages/excalidraw/CHANGELOG.md

@@ -16,6 +16,7 @@ Please add the latest change on the top under the correct section.
 
 ### Features
 
+- Add support for `exportToBackend` prop to allow host apps to implement shareable links [#2612](https://github.com/excalidraw/excalidraw/pull/2612/files)
 - Add zoom to selection [#2522](https://github.com/excalidraw/excalidraw/pull/2522)
 - Insert Library items in the middle of the screen [#2527](https://github.com/excalidraw/excalidraw/pull/2527)
 - Show shortcut context menu [#2501](https://github.com/excalidraw/excalidraw/pull/2501)
@@ -25,6 +26,7 @@ Please add the latest change on the top under the correct section.
 - Support CSV graphs and improve the look and feel [#2495](https://github.com/excalidraw/excalidraw/pull/2495)
 
 ### Fixes
+
 - Consistent case for export locale strings [#2622](https://github.com/excalidraw/excalidraw/pull/2622)
 - Remove unnecessary console.error as it was polluting Sentry [#2637](https://github.com/excalidraw/excalidraw/pull/2637)
 - Fix scroll-to-center on init for non-zero canvas offsets [#2445](https://github.com/excalidraw/excalidraw/pull/2445)

+ 13 - 0
src/packages/excalidraw/README.md

@@ -141,6 +141,7 @@ export default function App() {
 | [`onCollabButtonClick`](#onCollabButtonClick) | Function                                                                                                                                                                                                                                                                                                                                           |                      | Callback to be triggered when the collab button is clicked                                                                                                 |
 | [`isCollaborating`](#isCollaborating)         | `boolean`                                                                                                                                                                                                                                                                                                                                          |                      | This implies if the app is in collaboration mode                                                                                                           |
 | [`onPointerUpdate`](#onPointerUpdate)         | Function                                                                                                                                                                                                                                                                                                                                           |                      | Callback triggered when mouse pointer is updated.                                                                                                          |
+| [`onExportToBackend`](#onExportToBackend)     | Function                                                                                                                                                                                                                                                                                                                                           |                      | Callback triggered when link button is clicked on export dialog                                                                                            |
 
 #### `width`
 
@@ -260,3 +261,15 @@ This callback is triggered when mouse pointer is updated.
 2.`button`: The position of the button. This will be one of `["down", "up"]`
 
 3.`pointersMap`: [`pointers map`](https://github.com/excalidraw/excalidraw/blob/182a3e39e1362d73d2a565c870eb2fb72071fdcc/src/types.ts#L122) of the scene
+
+#### `onExportToBackend`
+
+This callback is triggered when the shareable-link button is clicked in the export dialog. The link button will only be shown if this callback is passed.
+
+```js
+(exportedElements, appState, canvas) => void
+```
+
+1. `exportedElements`: An array of [non deleted elements](https://github.com/excalidraw/excalidraw/blob/6e45cb95dbd7a8be1859c7055b06957298e3097c/src/element/types.ts#L76) which needs to be exported.
+2. `appState`: [AppState](https://github.com/excalidraw/excalidraw/blob/4c90ea5667d29effe8ec4a115e49efc7c340cdb3/src/types.ts#L33) of the scene.
+3. `canvas`: The `HTMLCanvasElement` of the scene.

+ 2 - 0
src/packages/excalidraw/index.tsx

@@ -22,6 +22,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
     onCollabButtonClick,
     isCollaborating,
     onPointerUpdate,
+    onExportToBackend,
   } = props;
 
   useEffect(() => {
@@ -57,6 +58,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
           onCollabButtonClick={onCollabButtonClick}
           isCollaborating={isCollaborating}
           onPointerUpdate={onPointerUpdate}
+          onExportToBackend={onExportToBackend}
         />
       </IsMobileProvider>
     </InitializeApp>

+ 5 - 0
src/types.ts

@@ -166,6 +166,11 @@ export interface ExcalidrawProps {
     button: "down" | "up";
     pointersMap: Gesture["pointers"];
   }) => void;
+  onExportToBackend?: (
+    exportedElements: readonly NonDeletedExcalidrawElement[],
+    appState: AppState,
+    canvas: HTMLCanvasElement | null,
+  ) => void;
 }
 
 export type SceneData = {