123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389 |
- import LanguageDetector from "i18next-browser-languagedetector";
- import React, {
- useCallback,
- 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 } from "../constants";
- import { ImportedDataState } from "../data/types";
- import {
- ExcalidrawElement,
- NonDeletedExcalidrawElement,
- } from "../element/types";
- import { Language, t } from "../i18n";
- import Excalidraw, {
- defaultLang,
- languages,
- } from "../packages/excalidraw/index";
- import { AppState, ExcalidrawAPIRefValue } from "../types";
- import {
- debounce,
- getVersion,
- ResolvablePromise,
- resolvablePromise,
- } from "../utils";
- import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
- import CollabWrapper, { CollabAPI } 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 excalidrawRef: React.MutableRefObject<
- MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
- > = {
- current: {
- readyPromise: resolvablePromise(),
- ready: 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<typeof loadScene>,
- ): 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<Scene | null> => {
- 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(props: { collab: CollabAPI }) {
- // 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<ImportedDataState | null>;
- }>({ promise: null! });
- if (!initialStatePromiseRef.current.promise) {
- initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
- }
- const { collab } = props;
- useEffect(() => {
- // delayed by 15 sec so that the app has a time to load the latest SW
- setTimeout(() => {
- const version = getVersion();
- const loggedVersion = window.localStorage.getItem(
- "excalidraw-lastLoggedVersion",
- );
- // prevent logging on multiple visits
- if (version && version !== loggedVersion) {
- window.localStorage.setItem("excalidraw-lastLoggedVersion", version);
- trackEvent("load", "version", version);
- }
- }, 15000);
- excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
- initializeScene({
- resetScene: excalidrawApi.resetScene,
- initializeSocketClient: collab.initializeSocketClient,
- }).then((scene) => {
- initialStatePromiseRef.current.promise.resolve(scene);
- });
- });
- const onHashChange = (_: HashChangeEvent) => {
- const api = excalidrawRef.current!;
- if (!api.ready) {
- return;
- }
- if (window.location.hash.length > 1) {
- initializeScene({
- resetScene: api.resetScene,
- initializeSocketClient: collab.initializeSocketClient,
- }).then((scene) => {
- if (scene) {
- api.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);
- };
- }, [collab.initializeSocketClient]);
- useEffect(() => {
- languageDetector.cacheUserLanguage(langCode);
- }, [langCode]);
- const onChange = (
- elements: readonly ExcalidrawElement[],
- appState: AppState,
- ) => {
- saveDebounced(elements, appState);
- if (collab.isCollaborating) {
- collab.broadcastElements(elements, appState);
- }
- };
- 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 = () => (
- <LanguageList
- onChange={(langCode) => {
- setLangCode(langCode);
- }}
- languages={languages}
- floating={!isMobile}
- currentLangCode={langCode}
- />
- );
- if (isMobile) {
- return (
- <fieldset>
- <legend>{t("labels.language")}</legend>
- {renderLanguageList()}
- </fieldset>
- );
- }
- return renderLanguageList();
- },
- [langCode],
- );
- return (
- <>
- <Excalidraw
- ref={excalidrawRef}
- onChange={onChange}
- width={dimensions.width}
- height={dimensions.height}
- initialData={initialStatePromiseRef.current.promise}
- user={{ name: collab.username }}
- onCollabButtonClick={collab.onCollabButtonClick}
- isCollaborating={collab.isCollaborating}
- onPointerUpdate={collab.onPointerUpdate}
- onExportToBackend={onExportToBackend}
- renderFooter={renderFooter}
- langCode={langCode}
- />
- {errorMessage && (
- <ErrorDialog
- message={errorMessage}
- onClose={() => setErrorMessage("")}
- />
- )}
- </>
- );
- }
- export default function ExcalidrawApp() {
- return (
- <TopErrorBoundary>
- <CollabWrapper
- excalidrawRef={
- excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI>
- }
- >
- {(collab) => <ExcalidrawWrapper collab={collab} />}
- </CollabWrapper>
- </TopErrorBoundary>
- );
- }
|