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>
This commit is contained in:
Brady Madden 2020-01-20 00:56:19 -05:00 committed by Christopher Chedeau
parent ad865907a6
commit 6ad596e9f1
6 changed files with 104 additions and 18 deletions

View File

@ -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(

View File

@ -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

View File

@ -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 (appState) {
this.setState(appState);
if (data.elements) {
elements = data.elements;
}
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",

View File

@ -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 function serializeAsJSON(
elements: readonly ExcalidrawElement[],
appState?: AppState
): string {
return JSON.stringify({
version: 1,
source: window.location.origin,
elements: elements.map(({ shape, ...el }) => el),
appState: appState || getDefaultAppState()
});
}
export async function saveAsJSON(
elements: readonly ExcalidrawElement[],
appState: AppState
) {
const serialized = JSON.stringify({
version: 1,
source: window.location.origin,
elements: elements.map(({ shape, ...el }) => el),
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

View File

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

View File

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