diff --git a/scripts/changelog-check.js b/scripts/changelog-check.js index 33d6dfdc..dddb359a 100644 --- a/scripts/changelog-check.js +++ b/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); } diff --git a/src/components/App.tsx b/src/components/App.tsx index a52781d8..229ef9bf 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -345,7 +345,7 @@ class App extends React.Component { 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 { toggleZenMode={this.toggleZenMode} lng={getLanguage().lng} isCollaborating={this.props.isCollaborating || false} + onExportToBackend={onExportToBackend} /> {this.state.showStats && ( void; }) => { const someElementIsSelected = isSomeElementSelected(elements, appState); @@ -155,13 +155,15 @@ const ExportModal = ({ onClick={() => onExportToClipboard(exportedElements, scale)} /> )} - onExportToBackend(exportedElements)} - /> + {onExportToBackend && ( + onExportToBackend(exportedElements)} + /> + )}
{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(null); diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index bf339580..6e4ebbeb 100644 --- a/src/components/LayerUI.tsx +++ b/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 ( { - 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 + } /> ); }; diff --git a/src/data/index.ts b/src/data/index.ts index 5989e931..6797a7a3 100644 --- a/src/data/index.ts +++ b/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 diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts index 38eae181..0eb52fd8 100644 --- a/src/excalidraw-app/data/index.ts +++ b/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")); + } +}; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 2fcd0b92..170c28e5 100644 --- a/src/excalidraw-app/index.tsx +++ b/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 @@ -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 ( - + <> + + {errorMessage && ( + setErrorMessage("")} + /> + )} + ); } diff --git a/src/packages/excalidraw/CHANGELOG.MD b/src/packages/excalidraw/CHANGELOG.md similarity index 94% rename from src/packages/excalidraw/CHANGELOG.MD rename to src/packages/excalidraw/CHANGELOG.md index 5cf522bf..24e4be6a 100644 --- a/src/packages/excalidraw/CHANGELOG.MD +++ b/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) diff --git a/src/packages/excalidraw/README.md b/src/packages/excalidraw/README.md index a439b9a7..3a6a0964 100644 --- a/src/packages/excalidraw/README.md +++ b/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. diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index b37e211c..57725097 100644 --- a/src/packages/excalidraw/index.tsx +++ b/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} /> diff --git a/src/types.ts b/src/types.ts index 785326d8..9c99376c 100644 --- a/src/types.ts +++ b/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 = {