index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. import LanguageDetector from "i18next-browser-languagedetector";
  2. import React, {
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useRef,
  7. useState,
  8. } from "react";
  9. import { trackEvent } from "../analytics";
  10. import { getDefaultAppState } from "../appState";
  11. import { ExcalidrawImperativeAPI } from "../components/App";
  12. import { ErrorDialog } from "../components/ErrorDialog";
  13. import { TopErrorBoundary } from "../components/TopErrorBoundary";
  14. import {
  15. APP_NAME,
  16. EVENT,
  17. STORAGE_KEYS,
  18. TITLE_TIMEOUT,
  19. URL_HASH_KEYS,
  20. VERSION_TIMEOUT,
  21. } from "../constants";
  22. import { loadFromBlob } from "../data/blob";
  23. import { ImportedDataState } from "../data/types";
  24. import {
  25. ExcalidrawElement,
  26. NonDeletedExcalidrawElement,
  27. } from "../element/types";
  28. import { useCallbackRefState } from "../hooks/useCallbackRefState";
  29. import { Language, t } from "../i18n";
  30. import Excalidraw, {
  31. defaultLang,
  32. languages,
  33. } from "../packages/excalidraw/index";
  34. import { AppState, LibraryItems } from "../types";
  35. import {
  36. debounce,
  37. getVersion,
  38. ResolvablePromise,
  39. resolvablePromise,
  40. } from "../utils";
  41. import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
  42. import CollabWrapper, {
  43. CollabAPI,
  44. CollabContext,
  45. CollabContextConsumer,
  46. } from "./collab/CollabWrapper";
  47. import { LanguageList } from "./components/LanguageList";
  48. import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
  49. import {
  50. importFromLocalStorage,
  51. saveToLocalStorage,
  52. } from "./data/localStorage";
  53. import CustomStats from "./CustomStats";
  54. import { RestoredDataState } from "../data/restore";
  55. const languageDetector = new LanguageDetector();
  56. languageDetector.init({
  57. languageUtils: {
  58. formatLanguageCode: (langCode: Language["code"]) => langCode,
  59. isWhitelisted: () => true,
  60. },
  61. checkWhitelist: false,
  62. });
  63. const saveDebounced = debounce(
  64. (elements: readonly ExcalidrawElement[], state: AppState) => {
  65. saveToLocalStorage(elements, state);
  66. },
  67. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  68. );
  69. const onBlur = () => {
  70. saveDebounced.flush();
  71. };
  72. const initializeScene = async (opts: {
  73. collabAPI: CollabAPI;
  74. }): Promise<ImportedDataState | null> => {
  75. const searchParams = new URLSearchParams(window.location.search);
  76. const id = searchParams.get("id");
  77. const jsonBackendMatch = window.location.hash.match(
  78. /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
  79. );
  80. const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
  81. const localDataState = importFromLocalStorage();
  82. let scene: RestoredDataState & {
  83. scrollToContent?: boolean;
  84. } = await loadScene(null, null, localDataState);
  85. let roomLinkData = getCollaborationLinkData(window.location.href);
  86. const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
  87. if (isExternalScene) {
  88. if (
  89. // don't prompt if scene is empty
  90. !scene.elements.length ||
  91. // don't prompt for collab scenes because we don't override local storage
  92. roomLinkData ||
  93. // otherwise, prompt whether user wants to override current scene
  94. window.confirm(t("alerts.loadSceneOverridePrompt"))
  95. ) {
  96. // Backwards compatibility with legacy url format
  97. if (id) {
  98. scene = await loadScene(id, null, localDataState);
  99. } else if (jsonBackendMatch) {
  100. scene = await loadScene(
  101. jsonBackendMatch[1],
  102. jsonBackendMatch[2],
  103. localDataState,
  104. );
  105. }
  106. scene.scrollToContent = true;
  107. if (!roomLinkData) {
  108. window.history.replaceState({}, APP_NAME, window.location.origin);
  109. }
  110. } else {
  111. // https://github.com/excalidraw/excalidraw/issues/1919
  112. if (document.hidden) {
  113. return new Promise((resolve, reject) => {
  114. window.addEventListener(
  115. "focus",
  116. () => initializeScene(opts).then(resolve).catch(reject),
  117. {
  118. once: true,
  119. },
  120. );
  121. });
  122. }
  123. roomLinkData = null;
  124. window.history.replaceState({}, APP_NAME, window.location.origin);
  125. }
  126. } else if (externalUrlMatch) {
  127. window.history.replaceState({}, APP_NAME, window.location.origin);
  128. const url = externalUrlMatch[1];
  129. try {
  130. const request = await fetch(window.decodeURIComponent(url));
  131. const data = await loadFromBlob(await request.blob(), null);
  132. if (
  133. !scene.elements.length ||
  134. window.confirm(t("alerts.loadSceneOverridePrompt"))
  135. ) {
  136. return data;
  137. }
  138. } catch (error) {
  139. return {
  140. appState: {
  141. errorMessage: t("alerts.invalidSceneUrl"),
  142. },
  143. };
  144. }
  145. }
  146. if (roomLinkData) {
  147. return opts.collabAPI.initializeSocketClient(roomLinkData);
  148. } else if (scene) {
  149. return scene;
  150. }
  151. return null;
  152. };
  153. const ExcalidrawWrapper = () => {
  154. const [errorMessage, setErrorMessage] = useState("");
  155. const currentLangCode = languageDetector.detect() || defaultLang.code;
  156. const [langCode, setLangCode] = useState(currentLangCode);
  157. // initial state
  158. // ---------------------------------------------------------------------------
  159. const initialStatePromiseRef = useRef<{
  160. promise: ResolvablePromise<ImportedDataState | null>;
  161. }>({ promise: null! });
  162. if (!initialStatePromiseRef.current.promise) {
  163. initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
  164. }
  165. useEffect(() => {
  166. // Delayed so that the app has a time to load the latest SW
  167. setTimeout(() => {
  168. trackEvent("load", "version", getVersion());
  169. }, VERSION_TIMEOUT);
  170. }, []);
  171. const [
  172. excalidrawAPI,
  173. excalidrawRefCallback,
  174. ] = useCallbackRefState<ExcalidrawImperativeAPI>();
  175. const collabAPI = useContext(CollabContext)?.api;
  176. useEffect(() => {
  177. if (!collabAPI || !excalidrawAPI) {
  178. return;
  179. }
  180. initializeScene({ collabAPI }).then((scene) => {
  181. if (scene) {
  182. try {
  183. scene.libraryItems =
  184. JSON.parse(
  185. localStorage.getItem(
  186. STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
  187. ) as string,
  188. ) || [];
  189. } catch (e) {
  190. console.error(e);
  191. }
  192. }
  193. initialStatePromiseRef.current.promise.resolve(scene);
  194. });
  195. const onHashChange = (event: HashChangeEvent) => {
  196. event.preventDefault();
  197. const hash = new URLSearchParams(window.location.hash.slice(1));
  198. const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
  199. if (libraryUrl) {
  200. // If hash changed and it contains library url, import it and replace
  201. // the url to its previous state (important in case of collaboration
  202. // and similar).
  203. // Using history API won't trigger another hashchange.
  204. window.history.replaceState({}, "", event.oldURL);
  205. excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
  206. } else {
  207. initializeScene({ collabAPI }).then((scene) => {
  208. if (scene) {
  209. excalidrawAPI.updateScene(scene);
  210. }
  211. });
  212. }
  213. };
  214. const titleTimeout = setTimeout(
  215. () => (document.title = APP_NAME),
  216. TITLE_TIMEOUT,
  217. );
  218. window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
  219. window.addEventListener(EVENT.UNLOAD, onBlur, false);
  220. window.addEventListener(EVENT.BLUR, onBlur, false);
  221. return () => {
  222. window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
  223. window.removeEventListener(EVENT.UNLOAD, onBlur, false);
  224. window.removeEventListener(EVENT.BLUR, onBlur, false);
  225. clearTimeout(titleTimeout);
  226. };
  227. }, [collabAPI, excalidrawAPI]);
  228. useEffect(() => {
  229. languageDetector.cacheUserLanguage(langCode);
  230. }, [langCode]);
  231. const onChange = (
  232. elements: readonly ExcalidrawElement[],
  233. appState: AppState,
  234. ) => {
  235. if (collabAPI?.isCollaborating()) {
  236. collabAPI.broadcastElements(elements);
  237. } else {
  238. // collab scenes are persisted to the server, so we don't have to persist
  239. // them locally, which has the added benefit of not overwriting whatever
  240. // the user was working on before joining
  241. saveDebounced(elements, appState);
  242. }
  243. };
  244. const onExportToBackend = async (
  245. exportedElements: readonly NonDeletedExcalidrawElement[],
  246. appState: AppState,
  247. canvas: HTMLCanvasElement | null,
  248. ) => {
  249. if (exportedElements.length === 0) {
  250. return window.alert(t("alerts.cannotExportEmptyCanvas"));
  251. }
  252. if (canvas) {
  253. try {
  254. await exportToBackend(exportedElements, {
  255. ...appState,
  256. viewBackgroundColor: appState.exportBackground
  257. ? appState.viewBackgroundColor
  258. : getDefaultAppState().viewBackgroundColor,
  259. });
  260. } catch (error) {
  261. if (error.name !== "AbortError") {
  262. const { width, height } = canvas;
  263. console.error(error, { width, height });
  264. setErrorMessage(error.message);
  265. }
  266. }
  267. }
  268. };
  269. const renderFooter = useCallback(
  270. (isMobile: boolean) => {
  271. const renderLanguageList = () => (
  272. <LanguageList
  273. onChange={(langCode) => {
  274. setLangCode(langCode);
  275. }}
  276. languages={languages}
  277. floating={!isMobile}
  278. currentLangCode={langCode}
  279. />
  280. );
  281. if (isMobile) {
  282. return (
  283. <fieldset>
  284. <legend>{t("labels.language")}</legend>
  285. {renderLanguageList()}
  286. </fieldset>
  287. );
  288. }
  289. return renderLanguageList();
  290. },
  291. [langCode],
  292. );
  293. const renderCustomStats = () => {
  294. return (
  295. <CustomStats
  296. setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
  297. />
  298. );
  299. };
  300. const onLibraryChange = async (items: LibraryItems) => {
  301. if (!items.length) {
  302. localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
  303. return;
  304. }
  305. const serializedItems = JSON.stringify(items);
  306. localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
  307. };
  308. return (
  309. <>
  310. <Excalidraw
  311. ref={excalidrawRefCallback}
  312. onChange={onChange}
  313. initialData={initialStatePromiseRef.current.promise}
  314. onCollabButtonClick={collabAPI?.onCollabButtonClick}
  315. isCollaborating={collabAPI?.isCollaborating()}
  316. onPointerUpdate={collabAPI?.onPointerUpdate}
  317. onExportToBackend={onExportToBackend}
  318. renderFooter={renderFooter}
  319. langCode={langCode}
  320. renderCustomStats={renderCustomStats}
  321. detectScroll={false}
  322. handleKeyboardGlobally={true}
  323. onLibraryChange={onLibraryChange}
  324. />
  325. {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
  326. {errorMessage && (
  327. <ErrorDialog
  328. message={errorMessage}
  329. onClose={() => setErrorMessage("")}
  330. />
  331. )}
  332. </>
  333. );
  334. };
  335. const ExcalidrawApp = () => {
  336. return (
  337. <TopErrorBoundary>
  338. <CollabContextConsumer>
  339. <ExcalidrawWrapper />
  340. </CollabContextConsumer>
  341. </TopErrorBoundary>
  342. );
  343. };
  344. export default ExcalidrawApp;