index.tsx 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  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. TITLE_TIMEOUT,
  18. URL_HASH_KEYS,
  19. VERSION_TIMEOUT,
  20. } from "../constants";
  21. import { loadFromBlob } from "../data/blob";
  22. import { DataState, ImportedDataState } from "../data/types";
  23. import {
  24. ExcalidrawElement,
  25. NonDeletedExcalidrawElement,
  26. } from "../element/types";
  27. import { useCallbackRefState } from "../hooks/useCallbackRefState";
  28. import { Language, t } from "../i18n";
  29. import Excalidraw, {
  30. defaultLang,
  31. languages,
  32. } from "../packages/excalidraw/index";
  33. import { AppState } from "../types";
  34. import {
  35. debounce,
  36. getVersion,
  37. ResolvablePromise,
  38. resolvablePromise,
  39. } from "../utils";
  40. import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
  41. import CollabWrapper, {
  42. CollabAPI,
  43. CollabContext,
  44. CollabContextConsumer,
  45. } from "./collab/CollabWrapper";
  46. import { LanguageList } from "./components/LanguageList";
  47. import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
  48. import {
  49. importFromLocalStorage,
  50. saveToLocalStorage,
  51. } from "./data/localStorage";
  52. import CustomStats from "./CustomStats";
  53. const languageDetector = new LanguageDetector();
  54. languageDetector.init({
  55. languageUtils: {
  56. formatLanguageCode: (langCode: Language["code"]) => langCode,
  57. isWhitelisted: () => true,
  58. },
  59. checkWhitelist: false,
  60. });
  61. const saveDebounced = debounce(
  62. (elements: readonly ExcalidrawElement[], state: AppState) => {
  63. saveToLocalStorage(elements, state);
  64. },
  65. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  66. );
  67. const onBlur = () => {
  68. saveDebounced.flush();
  69. };
  70. const initializeScene = async (opts: {
  71. collabAPI: CollabAPI;
  72. }): Promise<ImportedDataState | null> => {
  73. const searchParams = new URLSearchParams(window.location.search);
  74. const id = searchParams.get("id");
  75. const jsonBackendMatch = window.location.hash.match(
  76. /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
  77. );
  78. const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
  79. const initialData = importFromLocalStorage();
  80. let scene: DataState & { scrollToContent?: boolean } = await loadScene(
  81. null,
  82. null,
  83. initialData,
  84. );
  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, initialData);
  99. } else if (jsonBackendMatch) {
  100. scene = await loadScene(
  101. jsonBackendMatch[1],
  102. jsonBackendMatch[2],
  103. initialData,
  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. initialStatePromiseRef.current.promise.resolve(scene);
  182. });
  183. const onHashChange = (event: HashChangeEvent) => {
  184. event.preventDefault();
  185. const hash = new URLSearchParams(window.location.hash.slice(1));
  186. const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
  187. if (libraryUrl) {
  188. // If hash changed and it contains library url, import it and replace
  189. // the url to its previous state (important in case of collaboration
  190. // and similar).
  191. // Using history API won't trigger another hashchange.
  192. window.history.replaceState({}, "", event.oldURL);
  193. excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
  194. } else {
  195. initializeScene({ collabAPI }).then((scene) => {
  196. if (scene) {
  197. excalidrawAPI.updateScene(scene);
  198. }
  199. });
  200. }
  201. };
  202. const titleTimeout = setTimeout(
  203. () => (document.title = APP_NAME),
  204. TITLE_TIMEOUT,
  205. );
  206. window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
  207. window.addEventListener(EVENT.UNLOAD, onBlur, false);
  208. window.addEventListener(EVENT.BLUR, onBlur, false);
  209. return () => {
  210. window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
  211. window.removeEventListener(EVENT.UNLOAD, onBlur, false);
  212. window.removeEventListener(EVENT.BLUR, onBlur, false);
  213. clearTimeout(titleTimeout);
  214. };
  215. }, [collabAPI, excalidrawAPI]);
  216. useEffect(() => {
  217. languageDetector.cacheUserLanguage(langCode);
  218. }, [langCode]);
  219. const onChange = (
  220. elements: readonly ExcalidrawElement[],
  221. appState: AppState,
  222. ) => {
  223. if (collabAPI?.isCollaborating()) {
  224. collabAPI.broadcastElements(elements);
  225. } else {
  226. // collab scenes are persisted to the server, so we don't have to persist
  227. // them locally, which has the added benefit of not overwriting whatever
  228. // the user was working on before joining
  229. saveDebounced(elements, appState);
  230. }
  231. };
  232. const onExportToBackend = async (
  233. exportedElements: readonly NonDeletedExcalidrawElement[],
  234. appState: AppState,
  235. canvas: HTMLCanvasElement | null,
  236. ) => {
  237. if (exportedElements.length === 0) {
  238. return window.alert(t("alerts.cannotExportEmptyCanvas"));
  239. }
  240. if (canvas) {
  241. try {
  242. await exportToBackend(exportedElements, {
  243. ...appState,
  244. viewBackgroundColor: appState.exportBackground
  245. ? appState.viewBackgroundColor
  246. : getDefaultAppState().viewBackgroundColor,
  247. });
  248. } catch (error) {
  249. if (error.name !== "AbortError") {
  250. const { width, height } = canvas;
  251. console.error(error, { width, height });
  252. setErrorMessage(error.message);
  253. }
  254. }
  255. }
  256. };
  257. const renderFooter = useCallback(
  258. (isMobile: boolean) => {
  259. const renderLanguageList = () => (
  260. <LanguageList
  261. onChange={(langCode) => {
  262. setLangCode(langCode);
  263. }}
  264. languages={languages}
  265. floating={!isMobile}
  266. currentLangCode={langCode}
  267. />
  268. );
  269. if (isMobile) {
  270. return (
  271. <fieldset>
  272. <legend>{t("labels.language")}</legend>
  273. {renderLanguageList()}
  274. </fieldset>
  275. );
  276. }
  277. return renderLanguageList();
  278. },
  279. [langCode],
  280. );
  281. const renderCustomStats = () => {
  282. return (
  283. <CustomStats
  284. setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
  285. />
  286. );
  287. };
  288. return (
  289. <>
  290. <Excalidraw
  291. ref={excalidrawRefCallback}
  292. onChange={onChange}
  293. initialData={initialStatePromiseRef.current.promise}
  294. onCollabButtonClick={collabAPI?.onCollabButtonClick}
  295. isCollaborating={collabAPI?.isCollaborating()}
  296. onPointerUpdate={collabAPI?.onPointerUpdate}
  297. onExportToBackend={onExportToBackend}
  298. renderFooter={renderFooter}
  299. langCode={langCode}
  300. renderCustomStats={renderCustomStats}
  301. detectScroll={false}
  302. />
  303. {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
  304. {errorMessage && (
  305. <ErrorDialog
  306. message={errorMessage}
  307. onClose={() => setErrorMessage("")}
  308. />
  309. )}
  310. </>
  311. );
  312. };
  313. const ExcalidrawApp = () => {
  314. return (
  315. <TopErrorBoundary>
  316. <CollabContextConsumer>
  317. <ExcalidrawWrapper />
  318. </CollabContextConsumer>
  319. </TopErrorBoundary>
  320. );
  321. };
  322. export default ExcalidrawApp;