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); + }); + } +}