Quellcode durchsuchen

Share excalidrawings as links! (#356)

* shareable links

* fix

* review comments

* json-excaliber (#464)

* draw

* Boom

* backend

* Remove local

Co-authored-by: Lipis <lipiridis@gmail.com>
Brady Madden vor 5 Jahren
Ursprung
Commit
6ad596e9f1
6 geänderte Dateien mit 103 neuen und 17 gelöschten Zeilen
  1. 11 3
      src/components/ExportDialog.tsx
  2. 9 0
      src/components/icons.tsx
  3. 25 7
      src/index.tsx
  4. 54 5
      src/scene/data.ts
  5. 3 1
      src/scene/index.ts
  6. 1 1
      src/scene/types.ts

+ 11 - 3
src/components/ExportDialog.tsx

@@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef } from "react";
 
 import { Modal } from "./Modal";
 import { ToolIcon } from "./ToolIcon";
-import { clipboard, exportFile, downloadFile } from "./icons";
+import { clipboard, exportFile, downloadFile, link } from "./icons";
 import { Island } from "./Island";
 import { ExcalidrawElement } from "../element/types";
 import { AppState } from "../types";
@@ -30,7 +30,8 @@ export function ExportDialog({
   actionManager,
   syncActionResult,
   onExportToPng,
-  onExportToClipboard
+  onExportToClipboard,
+  onExportToBackend
 }: {
   appState: AppState;
   elements: readonly ExcalidrawElement[];
@@ -39,6 +40,7 @@ export function ExportDialog({
   syncActionResult: UpdaterFn;
   onExportToPng: ExportCB;
   onExportToClipboard: ExportCB;
+  onExportToBackend: ExportCB;
 }) {
   const someElementIsSelected = elements.some(element => element.isSelected);
   const [modalIsShown, setModalIsShown] = useState(false);
@@ -108,7 +110,6 @@ export function ExportDialog({
                     aria-label="Export to PNG"
                     onClick={() => onExportToPng(exportedElements, scale)}
                   />
-
                   {probablySupportsClipboard && (
                     <ToolIcon
                       type="button"
@@ -120,6 +121,13 @@ export function ExportDialog({
                       }
                     />
                   )}
+                  <ToolIcon
+                    type="button"
+                    icon={link}
+                    title="Get shareable link"
+                    aria-label="Get shareable link"
+                    onClick={() => onExportToBackend(exportedElements, 1)}
+                  />
                 </Stack.Row>
 
                 {actionManager.renderAction(

+ 9 - 0
src/components/icons.tsx

@@ -5,6 +5,15 @@
 
 import React from "react";
 
+export const link = (
+  <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 512 512">
+    <path
+      fill="currentColor"
+      d="M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z"
+    />
+  </svg>
+);
+
 export const save = (
   <svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
     <path

+ 25 - 7
src/index.tsx

@@ -27,7 +27,8 @@ import {
   hasBackground,
   hasStroke,
   hasText,
-  exportCanvas
+  exportCanvas,
+  importFromBackend
 } from "./scene";
 
 import { renderScene } from "./renderer";
@@ -210,7 +211,7 @@ export class App extends React.Component<{}, AppState> {
     e.preventDefault();
   };
 
-  public componentDidMount() {
+  public async componentDidMount() {
     document.addEventListener("copy", this.onCopy);
     document.addEventListener("paste", this.onPaste);
     document.addEventListener("cut", this.onCut);
@@ -219,14 +220,22 @@ export class App extends React.Component<{}, AppState> {
     document.addEventListener("mousemove", this.getCurrentCursorPosition);
     window.addEventListener("resize", this.onResize, false);
 
-    const { elements: newElements, appState } = restoreFromLocalStorage();
+    let data;
+    const searchParams = new URLSearchParams(window.location.search);
 
-    if (newElements) {
-      elements = newElements;
+    if (searchParams.get("json") != null) {
+      data = await importFromBackend(searchParams.get("json"));
+      window.history.replaceState({}, "Excalidraw", window.location.origin);
+    } else {
+      data = restoreFromLocalStorage();
+    }
+
+    if (data.elements) {
+      elements = data.elements;
     }
 
-    if (appState) {
-      this.setState(appState);
+    if (data.appState) {
+      this.setState(data.appState);
     } else {
       this.forceUpdate();
     }
@@ -510,6 +519,15 @@ export class App extends React.Component<{}, AppState> {
                   scale
                 });
             }}
+            onExportToBackend={exportedElements => {
+              if (this.canvas)
+                exportCanvas(
+                  "backend",
+                  exportedElements,
+                  this.canvas,
+                  this.state
+                );
+            }}
           />
           {this.actionManager.renderAction(
             "clearCanvas",

+ 54 - 5
src/scene/data.ts

@@ -9,6 +9,8 @@ import nanoid from "nanoid";
 
 const LOCAL_STORAGE_KEY = "excalidraw";
 const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
+const BACKEND_POST = "https://json.excalidraw.com/api/v1/post/";
+const BACKEND_GET = "https://json.excalidraw.com/api/v1/";
 
 // TODO: Defined globally, since file handles aren't yet serializable.
 // Once `FileSystemFileHandle` can be serialized, make this
@@ -73,16 +75,23 @@ interface DataState {
   appState: AppState;
 }
 
-export async function saveAsJSON(
+export function serializeAsJSON(
   elements: readonly ExcalidrawElement[],
-  appState: AppState
-) {
-  const serialized = JSON.stringify({
+  appState?: AppState
+): string {
+  return JSON.stringify({
     version: 1,
     source: window.location.origin,
     elements: elements.map(({ shape, ...el }) => el),
-    appState: appState
+    appState: appState || getDefaultAppState()
   });
+}
+
+export async function saveAsJSON(
+  elements: readonly ExcalidrawElement[],
+  appState: AppState
+) {
+  const serialized = serializeAsJSON(elements, appState);
 
   const name = `${appState.name}.json`;
   if ("chooseFileSystemEntries" in window) {
@@ -166,6 +175,44 @@ export async function loadFromJSON() {
   }
 }
 
+export async function exportToBackend(elements: readonly ExcalidrawElement[]) {
+  const response = await fetch(BACKEND_POST, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json"
+    },
+    body: serializeAsJSON(elements)
+  });
+  const json = await response.json();
+  if (json.hash) {
+    const url = new URL(window.location.href);
+    url.searchParams.append("json", json.hash);
+
+    await navigator.clipboard.writeText(url.toString());
+    window.alert("Copied shareable link " + url.toString() + " to clipboard");
+  } else {
+    window.alert("Couldn't create shareable link");
+  }
+}
+
+export async function importFromBackend(hash: string | null) {
+  let elements: readonly ExcalidrawElement[] = [];
+  let appState: AppState = getDefaultAppState();
+  const response = await fetch(`${BACKEND_GET}${hash}.json`).then(data =>
+    data.clone().json()
+  );
+  if (response != null) {
+    try {
+      elements = response.elements || elements;
+      appState = response.appState || appState;
+    } catch (error) {
+      window.alert("Importing from backend failed");
+      console.error(error);
+    }
+  }
+  return restore(elements, appState);
+}
+
 export async function exportCanvas(
   type: ExportType,
   elements: readonly ExcalidrawElement[],
@@ -221,6 +268,8 @@ export async function exportCanvas(
     } catch (err) {
       window.alert("Couldn't copy to clipboard. Try using Chrome browser.");
     }
+  } else if (type === "backend") {
+    exportToBackend(elements);
   }
 
   // clean up the DOM

+ 3 - 1
src/scene/index.ts

@@ -12,7 +12,9 @@ export {
   loadFromJSON,
   saveAsJSON,
   restoreFromLocalStorage,
-  saveToLocalStorage
+  saveToLocalStorage,
+  exportToBackend,
+  importFromBackend
 } from "./data";
 export {
   hasBackground,

+ 1 - 1
src/scene/types.ts

@@ -16,4 +16,4 @@ export interface Scene {
   elements: ExcalidrawTextElement[];
 }
 
-export type ExportType = "png" | "clipboard";
+export type ExportType = "png" | "clipboard" | "backend";