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:
parent
ad865907a6
commit
6ad596e9f1
@ -4,7 +4,7 @@ import React, { useState, useEffect, useRef } from "react";
|
|||||||
|
|
||||||
import { Modal } from "./Modal";
|
import { Modal } from "./Modal";
|
||||||
import { ToolIcon } from "./ToolIcon";
|
import { ToolIcon } from "./ToolIcon";
|
||||||
import { clipboard, exportFile, downloadFile } from "./icons";
|
import { clipboard, exportFile, downloadFile, link } from "./icons";
|
||||||
import { Island } from "./Island";
|
import { Island } from "./Island";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
@ -30,7 +30,8 @@ export function ExportDialog({
|
|||||||
actionManager,
|
actionManager,
|
||||||
syncActionResult,
|
syncActionResult,
|
||||||
onExportToPng,
|
onExportToPng,
|
||||||
onExportToClipboard
|
onExportToClipboard,
|
||||||
|
onExportToBackend
|
||||||
}: {
|
}: {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
elements: readonly ExcalidrawElement[];
|
elements: readonly ExcalidrawElement[];
|
||||||
@ -39,6 +40,7 @@ export function ExportDialog({
|
|||||||
syncActionResult: UpdaterFn;
|
syncActionResult: UpdaterFn;
|
||||||
onExportToPng: ExportCB;
|
onExportToPng: ExportCB;
|
||||||
onExportToClipboard: ExportCB;
|
onExportToClipboard: ExportCB;
|
||||||
|
onExportToBackend: ExportCB;
|
||||||
}) {
|
}) {
|
||||||
const someElementIsSelected = elements.some(element => element.isSelected);
|
const someElementIsSelected = elements.some(element => element.isSelected);
|
||||||
const [modalIsShown, setModalIsShown] = useState(false);
|
const [modalIsShown, setModalIsShown] = useState(false);
|
||||||
@ -108,7 +110,6 @@ export function ExportDialog({
|
|||||||
aria-label="Export to PNG"
|
aria-label="Export to PNG"
|
||||||
onClick={() => onExportToPng(exportedElements, scale)}
|
onClick={() => onExportToPng(exportedElements, scale)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{probablySupportsClipboard && (
|
{probablySupportsClipboard && (
|
||||||
<ToolIcon
|
<ToolIcon
|
||||||
type="button"
|
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>
|
</Stack.Row>
|
||||||
|
|
||||||
{actionManager.renderAction(
|
{actionManager.renderAction(
|
||||||
|
@ -5,6 +5,15 @@
|
|||||||
|
|
||||||
import React from "react";
|
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 = (
|
export const save = (
|
||||||
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
|
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
|
||||||
<path
|
<path
|
||||||
|
@ -27,7 +27,8 @@ import {
|
|||||||
hasBackground,
|
hasBackground,
|
||||||
hasStroke,
|
hasStroke,
|
||||||
hasText,
|
hasText,
|
||||||
exportCanvas
|
exportCanvas,
|
||||||
|
importFromBackend
|
||||||
} from "./scene";
|
} from "./scene";
|
||||||
|
|
||||||
import { renderScene } from "./renderer";
|
import { renderScene } from "./renderer";
|
||||||
@ -210,7 +211,7 @@ export class App extends React.Component<{}, AppState> {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
|
|
||||||
public componentDidMount() {
|
public async componentDidMount() {
|
||||||
document.addEventListener("copy", this.onCopy);
|
document.addEventListener("copy", this.onCopy);
|
||||||
document.addEventListener("paste", this.onPaste);
|
document.addEventListener("paste", this.onPaste);
|
||||||
document.addEventListener("cut", this.onCut);
|
document.addEventListener("cut", this.onCut);
|
||||||
@ -219,14 +220,22 @@ export class App extends React.Component<{}, AppState> {
|
|||||||
document.addEventListener("mousemove", this.getCurrentCursorPosition);
|
document.addEventListener("mousemove", this.getCurrentCursorPosition);
|
||||||
window.addEventListener("resize", this.onResize, false);
|
window.addEventListener("resize", this.onResize, false);
|
||||||
|
|
||||||
const { elements: newElements, appState } = restoreFromLocalStorage();
|
let data;
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
if (newElements) {
|
if (searchParams.get("json") != null) {
|
||||||
elements = newElements;
|
data = await importFromBackend(searchParams.get("json"));
|
||||||
|
window.history.replaceState({}, "Excalidraw", window.location.origin);
|
||||||
|
} else {
|
||||||
|
data = restoreFromLocalStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appState) {
|
if (data.elements) {
|
||||||
this.setState(appState);
|
elements = data.elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.appState) {
|
||||||
|
this.setState(data.appState);
|
||||||
} else {
|
} else {
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
}
|
}
|
||||||
@ -510,6 +519,15 @@ export class App extends React.Component<{}, AppState> {
|
|||||||
scale
|
scale
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onExportToBackend={exportedElements => {
|
||||||
|
if (this.canvas)
|
||||||
|
exportCanvas(
|
||||||
|
"backend",
|
||||||
|
exportedElements,
|
||||||
|
this.canvas,
|
||||||
|
this.state
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{this.actionManager.renderAction(
|
{this.actionManager.renderAction(
|
||||||
"clearCanvas",
|
"clearCanvas",
|
||||||
|
@ -9,6 +9,8 @@ import nanoid from "nanoid";
|
|||||||
|
|
||||||
const LOCAL_STORAGE_KEY = "excalidraw";
|
const LOCAL_STORAGE_KEY = "excalidraw";
|
||||||
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
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.
|
// TODO: Defined globally, since file handles aren't yet serializable.
|
||||||
// Once `FileSystemFileHandle` can be serialized, make this
|
// Once `FileSystemFileHandle` can be serialized, make this
|
||||||
@ -73,16 +75,23 @@ interface DataState {
|
|||||||
appState: AppState;
|
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(
|
export async function saveAsJSON(
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState
|
appState: AppState
|
||||||
) {
|
) {
|
||||||
const serialized = JSON.stringify({
|
const serialized = serializeAsJSON(elements, appState);
|
||||||
version: 1,
|
|
||||||
source: window.location.origin,
|
|
||||||
elements: elements.map(({ shape, ...el }) => el),
|
|
||||||
appState: appState
|
|
||||||
});
|
|
||||||
|
|
||||||
const name = `${appState.name}.json`;
|
const name = `${appState.name}.json`;
|
||||||
if ("chooseFileSystemEntries" in window) {
|
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(
|
export async function exportCanvas(
|
||||||
type: ExportType,
|
type: ExportType,
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -221,6 +268,8 @@ export async function exportCanvas(
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
window.alert("Couldn't copy to clipboard. Try using Chrome browser.");
|
window.alert("Couldn't copy to clipboard. Try using Chrome browser.");
|
||||||
}
|
}
|
||||||
|
} else if (type === "backend") {
|
||||||
|
exportToBackend(elements);
|
||||||
}
|
}
|
||||||
|
|
||||||
// clean up the DOM
|
// clean up the DOM
|
||||||
|
@ -12,7 +12,9 @@ export {
|
|||||||
loadFromJSON,
|
loadFromJSON,
|
||||||
saveAsJSON,
|
saveAsJSON,
|
||||||
restoreFromLocalStorage,
|
restoreFromLocalStorage,
|
||||||
saveToLocalStorage
|
saveToLocalStorage,
|
||||||
|
exportToBackend,
|
||||||
|
importFromBackend
|
||||||
} from "./data";
|
} from "./data";
|
||||||
export {
|
export {
|
||||||
hasBackground,
|
hasBackground,
|
||||||
|
@ -16,4 +16,4 @@ export interface Scene {
|
|||||||
elements: ExcalidrawTextElement[];
|
elements: ExcalidrawTextElement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExportType = "png" | "clipboard";
|
export type ExportType = "png" | "clipboard" | "backend";
|
||||||
|
Loading…
x
Reference in New Issue
Block a user