import LanguageDetector from "i18next-browser-languagedetector"; import React, { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { trackEvent } from "../analytics"; import { getDefaultAppState } from "../appState"; import { ExcalidrawImperativeAPI } from "../components/App"; import { ErrorDialog } from "../components/ErrorDialog"; import { TopErrorBoundary } from "../components/TopErrorBoundary"; import { APP_NAME, EVENT, TITLE_TIMEOUT, VERSION_TIMEOUT } from "../constants"; import { ImportedDataState } from "../data/types"; import { ExcalidrawElement, NonDeletedExcalidrawElement, } from "../element/types"; import { useCallbackRefState } from "../hooks/useCallbackRefState"; import { Language, t } from "../i18n"; import Excalidraw, { defaultLang, languages, } from "../packages/excalidraw/index"; import { AppState } from "../types"; import { debounce, getVersion, ResolvablePromise, resolvablePromise, } from "../utils"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants"; import CollabWrapper, { CollabAPI, CollabContext, CollabContextConsumer, } from "./collab/CollabWrapper"; import { LanguageList } from "./components/LanguageList"; import { exportToBackend, getCollaborationLinkData, loadScene } from "./data"; import { loadFromFirebase } from "./data/firebase"; import { importFromLocalStorage, saveToLocalStorage, STORAGE_KEYS, } from "./data/localStorage"; const languageDetector = new LanguageDetector(); languageDetector.init({ languageUtils: { formatLanguageCode: (langCode: Language["code"]) => langCode, isWhitelisted: () => true, }, checkWhitelist: false, }); const saveDebounced = debounce( (elements: readonly ExcalidrawElement[], state: AppState) => { saveToLocalStorage(elements, state); }, SAVE_TO_LOCAL_STORAGE_TIMEOUT, ); const onBlur = () => { saveDebounced.flush(); }; const shouldForceLoadScene = ( scene: ResolutionType, ): boolean => { if (!scene.elements.length) { return true; } const roomMatch = getCollaborationLinkData(window.location.href); if (!roomMatch) { return false; } const roomId = roomMatch[1]; let collabForceLoadFlag; try { collabForceLoadFlag = localStorage?.getItem( STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG, ); } catch {} if (collabForceLoadFlag) { try { const { room: previousRoom, timestamp, }: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag); // if loading same room as the one previously unloaded within 15sec // force reload without prompting if (previousRoom === roomId && Date.now() - timestamp < 15000) { return true; } } catch {} } return false; }; type Scene = ImportedDataState & { commitToHistory: boolean }; const initializeScene = async (opts: { resetScene: ExcalidrawImperativeAPI["resetScene"]; initializeSocketClient: CollabAPI["initializeSocketClient"]; }): Promise => { const searchParams = new URLSearchParams(window.location.search); const id = searchParams.get("id"); const jsonMatch = window.location.hash.match( /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/, ); const initialData = importFromLocalStorage(); let scene = await loadScene(null, null, initialData); let isCollabScene = !!getCollaborationLinkData(window.location.href); const isExternalScene = !!(id || jsonMatch || isCollabScene); if (isExternalScene) { if ( shouldForceLoadScene(scene) || window.confirm(t("alerts.loadSceneOverridePrompt")) ) { // Backwards compatibility with legacy url format if (id) { scene = await loadScene(id, null, initialData); } else if (jsonMatch) { scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData); } if (!isCollabScene) { window.history.replaceState({}, APP_NAME, window.location.origin); } } else { // https://github.com/excalidraw/excalidraw/issues/1919 if (document.hidden) { return new Promise((resolve, reject) => { window.addEventListener( "focus", () => initializeScene(opts).then(resolve).catch(reject), { once: true, }, ); }); } isCollabScene = false; window.history.replaceState({}, APP_NAME, window.location.origin); } } if (isCollabScene) { // when joining a room we don't want user's local scene data to be merged // into the remote scene opts.resetScene(); const scenePromise = opts.initializeSocketClient(); try { const [, roomId, roomKey] = getCollaborationLinkData( window.location.href, )!; const elements = await loadFromFirebase(roomId, roomKey); if (elements) { return { elements, commitToHistory: true, }; } return { ...(await scenePromise), commitToHistory: true, }; } catch (error) { // log the error and move on. other peers will sync us the scene. console.error(error); } return null; } else if (scene) { return scene; } return null; }; function ExcalidrawWrapper() { // dimensions // --------------------------------------------------------------------------- const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight, }); const [errorMessage, setErrorMessage] = useState(""); const currentLangCode = languageDetector.detect() || defaultLang.code; const [langCode, setLangCode] = useState(currentLangCode); useLayoutEffect(() => { const onResize = () => { setDimensions({ width: window.innerWidth, height: window.innerHeight, }); }; window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); // initial state // --------------------------------------------------------------------------- const initialStatePromiseRef = useRef<{ promise: ResolvablePromise; }>({ promise: null! }); if (!initialStatePromiseRef.current.promise) { initialStatePromiseRef.current.promise = resolvablePromise(); } useEffect(() => { // Delayed so that the app has a time to load the latest SW setTimeout(() => { trackEvent("load", "version", getVersion()); }, VERSION_TIMEOUT); }, []); const [ excalidrawAPI, excalidrawRefCallback, ] = useCallbackRefState(); const collabAPI = useContext(CollabContext)?.api; useEffect(() => { if (!collabAPI || !excalidrawAPI) { return; } initializeScene({ resetScene: excalidrawAPI.resetScene, initializeSocketClient: collabAPI.initializeSocketClient, }).then((scene) => { initialStatePromiseRef.current.promise.resolve(scene); }); const onHashChange = (_: HashChangeEvent) => { if (window.location.hash.length > 1) { initializeScene({ resetScene: excalidrawAPI.resetScene, initializeSocketClient: collabAPI.initializeSocketClient, }).then((scene) => { if (scene) { excalidrawAPI.updateScene(scene); } }); } }; const titleTimeout = setTimeout( () => (document.title = APP_NAME), TITLE_TIMEOUT, ); window.addEventListener(EVENT.HASHCHANGE, onHashChange, false); window.addEventListener(EVENT.UNLOAD, onBlur, false); window.addEventListener(EVENT.BLUR, onBlur, false); return () => { window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false); window.removeEventListener(EVENT.UNLOAD, onBlur, false); window.removeEventListener(EVENT.BLUR, onBlur, false); clearTimeout(titleTimeout); }; }, [collabAPI, excalidrawAPI]); useEffect(() => { languageDetector.cacheUserLanguage(langCode); }, [langCode]); const onChange = ( elements: readonly ExcalidrawElement[], appState: AppState, ) => { saveDebounced(elements, appState); if (collabAPI?.isCollaborating) { collabAPI.broadcastElements(elements); } }; 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); } } } }; const renderFooter = useCallback( (isMobile: boolean) => { const renderLanguageList = () => ( { setLangCode(langCode); }} languages={languages} floating={!isMobile} currentLangCode={langCode} /> ); if (isMobile) { return (
{t("labels.language")} {renderLanguageList()}
); } return renderLanguageList(); }, [langCode], ); return ( <> {excalidrawAPI && } {errorMessage && ( setErrorMessage("")} /> )} ); } export default function ExcalidrawApp() { return ( ); }