diff --git a/src/components/App.tsx b/src/components/App.tsx index 4a472805..ac1746a4 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -271,6 +271,10 @@ export type PointerDownState = Readonly<{ export type ExcalidrawImperativeAPI = | { updateScene: InstanceType["updateScene"]; + resetScene: InstanceType["resetScene"]; + getSceneElementsIncludingDeleted: InstanceType< + typeof App + >["getSceneElementsIncludingDeleted"]; } | undefined; @@ -306,6 +310,8 @@ class App extends React.Component { if (forwardedRef && "current" in forwardedRef) { forwardedRef.current = { updateScene: this.updateScene, + resetScene: this.resetScene, + getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, }; } this.scene = new Scene(); diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx new file mode 100644 index 00000000..c39de36a --- /dev/null +++ b/src/excalidraw-app/index.tsx @@ -0,0 +1,108 @@ +import React, { useState, useLayoutEffect, useEffect } from "react"; + +import { LoadingMessage } from "../components/LoadingMessage"; +import { TopErrorBoundary } from "../components/TopErrorBoundary"; +import Excalidraw from "../excalidraw-embed/index"; + +import { + importFromLocalStorage, + importUsernameFromLocalStorage, + saveToLocalStorage, + saveUsernameToLocalStorage, +} from "../data/localStorage"; + +import { debounce } from "../utils"; + +import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "../time_constants"; +import { EVENT } from "../constants"; + +import { ImportedDataState } from "../data/types"; +import { ExcalidrawElement } from "../element/types"; +import { AppState } from "../types"; + +const saveDebounced = debounce( + (elements: readonly ExcalidrawElement[], state: AppState) => { + saveToLocalStorage(elements, state); + }, + SAVE_TO_LOCAL_STORAGE_TIMEOUT, +); + +const onUsernameChange = (username: string) => { + saveUsernameToLocalStorage(username); +}; + +const onBlur = () => { + saveDebounced.flush(); +}; + +export default function ExcalidrawApp() { + // dimensions + // --------------------------------------------------------------------------- + + const [dimensions, setDimensions] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }); + useLayoutEffect(() => { + const onResize = () => { + setDimensions({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + window.addEventListener("resize", onResize); + + return () => window.removeEventListener("resize", onResize); + }, []); + + // initial state + // --------------------------------------------------------------------------- + + const [initialState, setInitialState] = useState<{ + data: ImportedDataState; + user: { + name: string | null; + }; + } | null>(null); + + useEffect(() => { + setInitialState({ + data: importFromLocalStorage(), + user: { + name: importUsernameFromLocalStorage(), + }, + }); + }, []); + + // blur/unload + // --------------------------------------------------------------------------- + + useEffect(() => { + window.addEventListener(EVENT.UNLOAD, onBlur, false); + window.addEventListener(EVENT.BLUR, onBlur, false); + return () => { + window.removeEventListener(EVENT.UNLOAD, onBlur, false); + window.removeEventListener(EVENT.BLUR, onBlur, false); + }; + }, []); + + // --------------------------------------------------------------------------- + + if (!initialState) { + return ; + } + + return ( + + + + ); +} diff --git a/src/excalidraw-app/pwa.ts b/src/excalidraw-app/pwa.ts new file mode 100644 index 00000000..44f14930 --- /dev/null +++ b/src/excalidraw-app/pwa.ts @@ -0,0 +1,31 @@ +import { register as registerServiceWorker } from "../serviceWorker"; +import { EVENT } from "../constants"; + +// On Apple mobile devices add the proprietary app icon and splashscreen markup. +// No one should have to do this manually, and eventually this annoyance will +// go away once https://bugs.webkit.org/show_bug.cgi?id=183937 is fixed. +if ( + /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent) && + !matchMedia("(display-mode: standalone)").matches +) { + import(/* webpackChunkName: "pwacompat" */ "pwacompat"); +} + +registerServiceWorker({ + onUpdate: (registration) => { + const waitingServiceWorker = registration.waiting; + if (waitingServiceWorker) { + waitingServiceWorker.addEventListener( + EVENT.STATE_CHANGE, + (event: Event) => { + const target = event.target as ServiceWorker; + const state = target.state as ServiceWorkerState; + if (state === "activated") { + window.location.reload(); + } + }, + ); + waitingServiceWorker.postMessage({ type: "SKIP_WAITING" }); + } + }, +}); diff --git a/src/excalidraw-app/sentry.ts b/src/excalidraw-app/sentry.ts new file mode 100644 index 00000000..04b3246d --- /dev/null +++ b/src/excalidraw-app/sentry.ts @@ -0,0 +1,39 @@ +import * as Sentry from "@sentry/browser"; +import * as SentryIntegrations from "@sentry/integrations"; + +const SentryEnvHostnameMap: { [key: string]: string } = { + "excalidraw.com": "production", + "vercel.app": "staging", +}; + +const REACT_APP_DISABLE_SENTRY = + process.env.REACT_APP_DISABLE_SENTRY === "true"; + +// Disable Sentry locally or inside the Docker to avoid noise/respect privacy +const onlineEnv = + !REACT_APP_DISABLE_SENTRY && + Object.keys(SentryEnvHostnameMap).find( + (item) => window.location.hostname.indexOf(item) >= 0, + ); + +Sentry.init({ + dsn: onlineEnv + ? "https://7bfc596a5bf945eda6b660d3015a5460@sentry.io/5179260" + : undefined, + environment: onlineEnv ? SentryEnvHostnameMap[onlineEnv] : undefined, + release: process.env.REACT_APP_GIT_SHA, + ignoreErrors: [ + "undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything + ], + integrations: [ + new SentryIntegrations.CaptureConsole({ + levels: ["error"], + }), + ], + beforeSend(event) { + if (event.request?.url) { + event.request.url = event.request.url.replace(/#.*$/, ""); + } + return event; + }, +}); diff --git a/src/global.d.ts b/src/global.d.ts index f533e976..193a78a8 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -10,7 +10,7 @@ interface Document { interface Window { ClipboardItem: any; - __EXCALIDRAW_SHA__: string; + __EXCALIDRAW_SHA__: string | undefined; } // https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts diff --git a/src/index.tsx b/src/index.tsx index 11e035fb..69330d90 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,185 +1,10 @@ -import React, { useState, useLayoutEffect, useEffect } from "react"; +import React from "react"; import ReactDOM from "react-dom"; -import * as Sentry from "@sentry/browser"; -import * as SentryIntegrations from "@sentry/integrations"; +import ExcalidrawApp from "./excalidraw-app"; -import { EVENT } from "./constants"; -import { TopErrorBoundary } from "./components/TopErrorBoundary"; -import Excalidraw from "./excalidraw-embed/index"; -import { register as registerServiceWorker } from "./serviceWorker"; +import "./excalidraw-app/pwa"; +import "./excalidraw-app/sentry"; -import { debounce } from "./utils"; -import { - importFromLocalStorage, - importUsernameFromLocalStorage, - saveUsernameToLocalStorage, - saveToLocalStorage, -} from "./data/localStorage"; +window.__EXCALIDRAW_SHA__ = process.env.REACT_APP_GIT_SHA; -import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./time_constants"; -import { ImportedDataState } from "./data/types"; -import { LoadingMessage } from "./components/LoadingMessage"; -import { ExcalidrawElement } from "./element/types"; -import { AppState } from "./types"; - -// On Apple mobile devices add the proprietary app icon and splashscreen markup. -// No one should have to do this manually, and eventually this annoyance will -// go away once https://bugs.webkit.org/show_bug.cgi?id=183937 is fixed. -if ( - /\b(iPad|iPhone|iPod)\b/.test(navigator.userAgent) && - !matchMedia("(display-mode: standalone)").matches -) { - import(/* webpackChunkName: "pwacompat" */ "pwacompat"); -} - -const SentryEnvHostnameMap: { [key: string]: string } = { - "excalidraw.com": "production", - "vercel.app": "staging", -}; - -const REACT_APP_DISABLE_SENTRY = - process.env.REACT_APP_DISABLE_SENTRY === "true"; -const REACT_APP_GIT_SHA = process.env.REACT_APP_GIT_SHA as string; - -// Disable Sentry locally or inside the Docker to avoid noise/respect privacy -const onlineEnv = - !REACT_APP_DISABLE_SENTRY && - Object.keys(SentryEnvHostnameMap).find( - (item) => window.location.hostname.indexOf(item) >= 0, - ); - -Sentry.init({ - dsn: onlineEnv - ? "https://7bfc596a5bf945eda6b660d3015a5460@sentry.io/5179260" - : undefined, - environment: onlineEnv ? SentryEnvHostnameMap[onlineEnv] : undefined, - release: REACT_APP_GIT_SHA, - ignoreErrors: [ - "undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything - ], - integrations: [ - new SentryIntegrations.CaptureConsole({ - levels: ["error"], - }), - ], - beforeSend(event) { - if (event.request?.url) { - event.request.url = event.request.url.replace(/#.*$/, ""); - } - return event; - }, -}); - -window.__EXCALIDRAW_SHA__ = REACT_APP_GIT_SHA; - -const saveDebounced = debounce( - (elements: readonly ExcalidrawElement[], state: AppState) => { - saveToLocalStorage(elements, state); - }, - SAVE_TO_LOCAL_STORAGE_TIMEOUT, -); - -const onUsernameChange = (username: string) => { - saveUsernameToLocalStorage(username); -}; - -const onBlur = () => { - saveDebounced.flush(); -}; - -function ExcalidrawApp() { - // dimensions - // --------------------------------------------------------------------------- - - const [dimensions, setDimensions] = useState({ - width: window.innerWidth, - height: window.innerHeight, - }); - useLayoutEffect(() => { - const onResize = () => { - setDimensions({ - width: window.innerWidth, - height: window.innerHeight, - }); - }; - - window.addEventListener("resize", onResize); - - return () => window.removeEventListener("resize", onResize); - }, []); - - // initial state - // --------------------------------------------------------------------------- - - const [initialState, setInitialState] = useState<{ - data: ImportedDataState; - user: { - name: string | null; - }; - } | null>(null); - - useEffect(() => { - setInitialState({ - data: importFromLocalStorage(), - user: { - name: importUsernameFromLocalStorage(), - }, - }); - }, []); - - // blur/unload - // --------------------------------------------------------------------------- - - useEffect(() => { - window.addEventListener(EVENT.UNLOAD, onBlur, false); - window.addEventListener(EVENT.BLUR, onBlur, false); - return () => { - window.removeEventListener(EVENT.UNLOAD, onBlur, false); - window.removeEventListener(EVENT.BLUR, onBlur, false); - }; - }, []); - - // --------------------------------------------------------------------------- - - if (!initialState) { - return ; - } - - return ( - - - - ); -} - -const rootElement = document.getElementById("root"); - -ReactDOM.render(, rootElement); - -registerServiceWorker({ - onUpdate: (registration) => { - const waitingServiceWorker = registration.waiting; - if (waitingServiceWorker) { - waitingServiceWorker.addEventListener( - EVENT.STATE_CHANGE, - (event: Event) => { - const target = event.target as ServiceWorker; - const state = target.state as ServiceWorkerState; - if (state === "activated") { - window.location.reload(); - } - }, - ); - waitingServiceWorker.postMessage({ type: "SKIP_WAITING" }); - } - }, -}); +ReactDOM.render(, document.getElementById("root"));