diff --git a/now.json b/now.json
index fae86942..fa3a3204 100644
--- a/now.json
+++ b/now.json
@@ -21,7 +21,7 @@
},
{
"key": "Content-Security-Policy",
- "value": "default-src https: data: 'unsafe-inline'; connect-src https://*.excalidraw.com wss://excalidraw-socket.herokuapp.com https://excalidraw-socket.herokuapp.com https://sentry.io"
+ "value": "default-src https: data: 'unsafe-inline'; connect-src https://*.excalidraw.com https://*.excalidraw.now.sh wss://excalidraw-socket.herokuapp.com https://excalidraw-socket.herokuapp.com https://sentry.io;"
}
]
}
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
index 518c8962..e228a732 100644
Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ
diff --git a/public/index.html b/public/index.html
index b8bf8f4c..11aaa50b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -9,6 +9,8 @@
/>
+
+
@@ -64,24 +66,9 @@
-
-
-
;
+ onCloseRequest: (id: number) => void;
+}) {
+ return (
+
+ {[...props.toasts.entries()].map(([id, toast]) => (
+
+
+
{toast}
+
+
+
+ ))}
+
+ );
+}
+
+let toastsRootNode: HTMLDivElement;
+function getToastsRootNode() {
+ return toastsRootNode || (toastsRootNode = initToastsRootNode());
+}
+
+function initToastsRootNode() {
+ const div = window.document.createElement("div");
+ document.body.appendChild(div);
+ div.className = "Toast__container";
+ return div;
+}
+
+function renderToasts(
+ toasts: Map,
+ onCloseRequest: (id: number) => void,
+) {
+ render(
+ ,
+ getToastsRootNode(),
+ );
+}
+
+let incrementalId = 0;
+function getToastId() {
+ return incrementalId++;
+}
+
+class ToastManager {
+ private toasts = new Map();
+ private timers = new Map();
+
+ public push(message: React.ReactNode, shiftAfterMs: number) {
+ const id = getToastId();
+ this.toasts.set(id, message);
+ if (isFinite(shiftAfterMs)) {
+ const handle = window.setTimeout(() => this.pop(id), shiftAfterMs);
+ this.timers.set(id, handle);
+ }
+ this.render();
+ }
+
+ private pop = (id: number) => {
+ const handle = this.timers.get(id);
+ if (handle) {
+ window.clearTimeout(handle);
+ this.timers.delete(id);
+ }
+ this.toasts.delete(id);
+ this.render();
+ };
+
+ private render() {
+ renderToasts(this.toasts, this.pop);
+ }
+}
+
+let toastManagerInstance: ToastManager;
+function getToastManager(): ToastManager {
+ return toastManagerInstance ?? (toastManagerInstance = new ToastManager());
+}
+
+export function push(message: React.ReactNode, manualClose = false) {
+ const toastManager = getToastManager();
+ toastManager.push(message, manualClose ? Infinity : TOAST_TIMEOUT);
+}
diff --git a/src/_variables.scss b/src/css/_variables.scss
similarity index 100%
rename from src/_variables.scss
rename to src/css/_variables.scss
diff --git a/public/fonts.css b/src/css/fonts.scss
similarity index 74%
rename from public/fonts.css
rename to src/css/fonts.scss
index 165bf6a5..87b5339e 100644
--- a/public/fonts.css
+++ b/src/css/fonts.scss
@@ -1,13 +1,13 @@
/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
@font-face {
font-family: "Virgil";
- src: url("FG_Virgil.woff2");
+ src: url("../fonts/FG_Virgil.woff2");
font-display: swap;
}
/* https://github.com/microsoft/cascadia-code */
@font-face {
font-family: "Cascadia";
- src: url("Cascadia.woff2");
+ src: url("../fonts/Cascadia.woff2");
font-display: swap;
}
diff --git a/src/styles.scss b/src/css/styles.scss
similarity index 99%
rename from src/styles.scss
rename to src/css/styles.scss
index b5bb79b8..dfa40772 100644
--- a/src/styles.scss
+++ b/src/css/styles.scss
@@ -1,4 +1,6 @@
@import "./_variables";
+@import "./theme";
+@import "./fonts";
:root {
--sat: env(safe-area-inset-top);
diff --git a/src/css/theme.scss b/src/css/theme.scss
new file mode 100644
index 00000000..8e8107ee
--- /dev/null
+++ b/src/css/theme.scss
@@ -0,0 +1,7 @@
+:root {
+ --text-color-primary: #343a40;
+ --bg-color-main: #fff;
+ --shadow-island: 0 1px 5px rgba(0, 0, 0, 0.15);
+ --border-radius-m: 4px;
+ --space-factor: 0.25rem;
+}
diff --git a/public/Cascadia.ttf b/src/fonts/Cascadia.ttf
similarity index 100%
rename from public/Cascadia.ttf
rename to src/fonts/Cascadia.ttf
diff --git a/public/Cascadia.woff2 b/src/fonts/Cascadia.woff2
similarity index 100%
rename from public/Cascadia.woff2
rename to src/fonts/Cascadia.woff2
diff --git a/public/FG_Virgil.otf b/src/fonts/FG_Virgil.otf
similarity index 100%
rename from public/FG_Virgil.otf
rename to src/fonts/FG_Virgil.otf
diff --git a/public/FG_Virgil.ttf b/src/fonts/FG_Virgil.ttf
similarity index 100%
rename from public/FG_Virgil.ttf
rename to src/fonts/FG_Virgil.ttf
diff --git a/public/FG_Virgil.woff2 b/src/fonts/FG_Virgil.woff2
similarity index 100%
rename from public/FG_Virgil.woff2
rename to src/fonts/FG_Virgil.woff2
diff --git a/src/index-node.ts b/src/index-node.ts
index 29c83b4f..d830d8a1 100644
--- a/src/index-node.ts
+++ b/src/index-node.ts
@@ -54,8 +54,9 @@ const elements = [
},
];
-registerFont("./public/FG_Virgil.ttf", { family: "Virgil" });
-registerFont("./public/Cascadia.ttf", { family: "Cascadia" });
+registerFont("./static/fonts/FG_Virgil.ttf", { family: "Virgil" });
+registerFont("./static/fonts/Cascadia.ttf", { family: "Cascadia" });
+
const canvas = exportToCanvas(
elements as any,
getDefaultAppState(),
diff --git a/src/index.tsx b/src/index.tsx
index 9cbe023d..22692fc1 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -2,10 +2,13 @@ import React from "react";
import ReactDOM from "react-dom";
import * as Sentry from "@sentry/browser";
import * as SentryIntegrations from "@sentry/integrations";
+
import { TopErrorBoundary } from "./components/TopErrorBoundary";
import { IsMobileProvider } from "./is-mobile";
import App from "./components/App";
-import "./styles.scss";
+import { register as registerServiceWorker } from "./serviceWorker";
+
+import "./css/styles.scss";
const SentryEnvHostnameMap: { [key: string]: string } = {
"excalidraw.com": "production",
@@ -52,3 +55,5 @@ ReactDOM.render(
,
rootElement,
);
+
+registerServiceWorker();
diff --git a/src/scene/export.ts b/src/scene/export.ts
index 311a2518..37178508 100644
--- a/src/scene/export.ts
+++ b/src/scene/export.ts
@@ -94,11 +94,11 @@ export function exportToSvg(
diff --git a/src/serviceWorker.tsx b/src/serviceWorker.tsx
new file mode 100644
index 00000000..8e1a6231
--- /dev/null
+++ b/src/serviceWorker.tsx
@@ -0,0 +1,146 @@
+// This optional code is used to register a service worker.
+// register() is not called by default.
+
+import { push } from "./components/Toast";
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on subsequent visits to a page, after all the
+// existing tabs open on the page have been closed, since previously cached
+// resources are updated in the background.
+
+// To learn more about the benefits of this model and instructions on how to
+// opt-in, read https://bit.ly/CRA-PWA
+
+const isLocalhost = Boolean(
+ window.location.hostname === "localhost" ||
+ // [::1] is the IPv6 localhost address.
+ window.location.hostname === "[::1]" ||
+ // 127.0.0.0/8 are considered localhost for IPv4.
+ window.location.hostname.match(
+ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/,
+ ),
+);
+
+type Config = {
+ onSuccess?: (registration: ServiceWorkerRegistration) => void;
+ onUpdate?: (registration: ServiceWorkerRegistration) => void;
+};
+
+export function register(config?: Config) {
+ if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebook/create-react-app/issues/2374
+ return;
+ }
+
+ window.addEventListener("load", () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+ if (isLocalhost) {
+ // This is running on localhost. Let's check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl, config);
+
+ // Add some additional logging to localhost, pointing developers to the
+ // service worker/PWA documentation.
+ navigator.serviceWorker.ready.then(() => {
+ console.info(
+ "This web app is being served cache-first by a service " +
+ "worker. To learn more, visit https://bit.ly/CRA-PWA",
+ );
+ });
+ } else {
+ // Is not localhost. Just register service worker
+ registerValidSW(swUrl, config);
+ }
+ });
+ }
+}
+
+function registerValidSW(swUrl: string, config?: Config) {
+ navigator.serviceWorker
+ .register(swUrl)
+ .then((registration) => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ if (installingWorker == null) {
+ return;
+ }
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === "installed") {
+ if (navigator.serviceWorker.controller) {
+ // At this point, the updated precached content has been fetched,
+ // but the previous service worker will still serve the older
+ // content until all client tabs are closed.
+ push(
+ "New content is available and will be used when all " +
+ "tabs for this page are closed.",
+ );
+
+ // Execute callback
+ if (config && config.onUpdate) {
+ config.onUpdate(registration);
+ }
+ } else {
+ // At this point, everything has been precached.
+ // It's the perfect time to display a
+ // "Content is cached for offline use." message.
+ push("Content is cached for offline use.");
+
+ // Execute callback
+ if (config && config.onSuccess) {
+ config.onSuccess(registration);
+ }
+ }
+ }
+ };
+ };
+ })
+ .catch((error) => {
+ console.error("Error during service worker registration:", error);
+ });
+}
+
+function checkValidServiceWorker(swUrl: string, config?: Config) {
+ // Check if the service worker can be found. If it can't reload the page.
+ fetch(swUrl, {
+ headers: { "Service-Worker": "script" },
+ })
+ .then((response) => {
+ // Ensure service worker exists, and that we really are getting a JS file.
+ const contentType = response.headers.get("content-type");
+ if (
+ response.status === 404 ||
+ (contentType != null && contentType.indexOf("javascript") === -1)
+ ) {
+ // No service worker found. Probably a different app. Reload the page.
+ navigator.serviceWorker.ready.then((registration) => {
+ registration.unregister().then(() => {
+ window.location.reload();
+ });
+ });
+ } else {
+ // Service worker found. Proceed as normal.
+ registerValidSW(swUrl, config);
+ }
+ })
+ .catch(() => {
+ push("No internet connection found. App is running in offline mode.");
+ });
+}
+
+export function unregister() {
+ if ("serviceWorker" in navigator) {
+ navigator.serviceWorker.ready
+ .then((registration) => {
+ registration.unregister();
+ })
+ .catch((error) => {
+ console.error(error.message);
+ });
+ }
+}