index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. import LanguageDetector from "i18next-browser-languagedetector";
  2. import React, {
  3. useCallback,
  4. useEffect,
  5. useLayoutEffect,
  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 { APP_NAME, EVENT, TITLE_TIMEOUT } from "../constants";
  15. import { ImportedDataState } from "../data/types";
  16. import {
  17. ExcalidrawElement,
  18. NonDeletedExcalidrawElement,
  19. } from "../element/types";
  20. import { Language, t } from "../i18n";
  21. import Excalidraw, {
  22. defaultLang,
  23. languages,
  24. } from "../packages/excalidraw/index";
  25. import { AppState, ExcalidrawAPIRefValue } from "../types";
  26. import {
  27. debounce,
  28. getVersion,
  29. ResolvablePromise,
  30. resolvablePromise,
  31. } from "../utils";
  32. import { SAVE_TO_LOCAL_STORAGE_TIMEOUT } from "./app_constants";
  33. import CollabWrapper, { CollabAPI } from "./collab/CollabWrapper";
  34. import { LanguageList } from "./components/LanguageList";
  35. import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
  36. import { loadFromFirebase } from "./data/firebase";
  37. import {
  38. importFromLocalStorage,
  39. saveToLocalStorage,
  40. STORAGE_KEYS,
  41. } from "./data/localStorage";
  42. const languageDetector = new LanguageDetector();
  43. languageDetector.init({
  44. languageUtils: {
  45. formatLanguageCode: (langCode: Language["code"]) => langCode,
  46. isWhitelisted: () => true,
  47. },
  48. checkWhitelist: false,
  49. });
  50. const excalidrawRef: React.MutableRefObject<
  51. MarkRequired<ExcalidrawAPIRefValue, "ready" | "readyPromise">
  52. > = {
  53. current: {
  54. readyPromise: resolvablePromise(),
  55. ready: false,
  56. },
  57. };
  58. const saveDebounced = debounce(
  59. (elements: readonly ExcalidrawElement[], state: AppState) => {
  60. saveToLocalStorage(elements, state);
  61. },
  62. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  63. );
  64. const onBlur = () => {
  65. saveDebounced.flush();
  66. };
  67. const shouldForceLoadScene = (
  68. scene: ResolutionType<typeof loadScene>,
  69. ): boolean => {
  70. if (!scene.elements.length) {
  71. return true;
  72. }
  73. const roomMatch = getCollaborationLinkData(window.location.href);
  74. if (!roomMatch) {
  75. return false;
  76. }
  77. const roomId = roomMatch[1];
  78. let collabForceLoadFlag;
  79. try {
  80. collabForceLoadFlag = localStorage?.getItem(
  81. STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
  82. );
  83. } catch {}
  84. if (collabForceLoadFlag) {
  85. try {
  86. const {
  87. room: previousRoom,
  88. timestamp,
  89. }: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag);
  90. // if loading same room as the one previously unloaded within 15sec
  91. // force reload without prompting
  92. if (previousRoom === roomId && Date.now() - timestamp < 15000) {
  93. return true;
  94. }
  95. } catch {}
  96. }
  97. return false;
  98. };
  99. type Scene = ImportedDataState & { commitToHistory: boolean };
  100. const initializeScene = async (opts: {
  101. resetScene: ExcalidrawImperativeAPI["resetScene"];
  102. initializeSocketClient: CollabAPI["initializeSocketClient"];
  103. }): Promise<Scene | null> => {
  104. const searchParams = new URLSearchParams(window.location.search);
  105. const id = searchParams.get("id");
  106. const jsonMatch = window.location.hash.match(
  107. /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
  108. );
  109. const initialData = importFromLocalStorage();
  110. let scene = await loadScene(null, null, initialData);
  111. let isCollabScene = !!getCollaborationLinkData(window.location.href);
  112. const isExternalScene = !!(id || jsonMatch || isCollabScene);
  113. if (isExternalScene) {
  114. if (
  115. shouldForceLoadScene(scene) ||
  116. window.confirm(t("alerts.loadSceneOverridePrompt"))
  117. ) {
  118. // Backwards compatibility with legacy url format
  119. if (id) {
  120. scene = await loadScene(id, null, initialData);
  121. } else if (jsonMatch) {
  122. scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
  123. }
  124. if (!isCollabScene) {
  125. window.history.replaceState({}, APP_NAME, window.location.origin);
  126. }
  127. } else {
  128. // https://github.com/excalidraw/excalidraw/issues/1919
  129. if (document.hidden) {
  130. return new Promise((resolve, reject) => {
  131. window.addEventListener(
  132. "focus",
  133. () => initializeScene(opts).then(resolve).catch(reject),
  134. {
  135. once: true,
  136. },
  137. );
  138. });
  139. }
  140. isCollabScene = false;
  141. window.history.replaceState({}, APP_NAME, window.location.origin);
  142. }
  143. }
  144. if (isCollabScene) {
  145. // when joining a room we don't want user's local scene data to be merged
  146. // into the remote scene
  147. opts.resetScene();
  148. const scenePromise = opts.initializeSocketClient();
  149. try {
  150. const [, roomId, roomKey] = getCollaborationLinkData(
  151. window.location.href,
  152. )!;
  153. const elements = await loadFromFirebase(roomId, roomKey);
  154. if (elements) {
  155. return {
  156. elements,
  157. commitToHistory: true,
  158. };
  159. }
  160. return {
  161. ...(await scenePromise),
  162. commitToHistory: true,
  163. };
  164. } catch (error) {
  165. // log the error and move on. other peers will sync us the scene.
  166. console.error(error);
  167. }
  168. return null;
  169. } else if (scene) {
  170. return scene;
  171. }
  172. return null;
  173. };
  174. function ExcalidrawWrapper(props: { collab: CollabAPI }) {
  175. // dimensions
  176. // ---------------------------------------------------------------------------
  177. const [dimensions, setDimensions] = useState({
  178. width: window.innerWidth,
  179. height: window.innerHeight,
  180. });
  181. const [errorMessage, setErrorMessage] = useState("");
  182. const currentLangCode = languageDetector.detect() || defaultLang.code;
  183. const [langCode, setLangCode] = useState(currentLangCode);
  184. useLayoutEffect(() => {
  185. const onResize = () => {
  186. setDimensions({
  187. width: window.innerWidth,
  188. height: window.innerHeight,
  189. });
  190. };
  191. window.addEventListener("resize", onResize);
  192. return () => window.removeEventListener("resize", onResize);
  193. }, []);
  194. // initial state
  195. // ---------------------------------------------------------------------------
  196. const initialStatePromiseRef = useRef<{
  197. promise: ResolvablePromise<ImportedDataState | null>;
  198. }>({ promise: null! });
  199. if (!initialStatePromiseRef.current.promise) {
  200. initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
  201. }
  202. const { collab } = props;
  203. useEffect(() => {
  204. // delayed by 15 sec so that the app has a time to load the latest SW
  205. setTimeout(() => {
  206. const version = getVersion();
  207. const loggedVersion = window.localStorage.getItem(
  208. "excalidraw-lastLoggedVersion",
  209. );
  210. // prevent logging on multiple visits
  211. if (version && version !== loggedVersion) {
  212. window.localStorage.setItem("excalidraw-lastLoggedVersion", version);
  213. trackEvent("load", "version", version);
  214. }
  215. }, 15000);
  216. excalidrawRef.current!.readyPromise.then((excalidrawApi) => {
  217. initializeScene({
  218. resetScene: excalidrawApi.resetScene,
  219. initializeSocketClient: collab.initializeSocketClient,
  220. }).then((scene) => {
  221. initialStatePromiseRef.current.promise.resolve(scene);
  222. });
  223. });
  224. const onHashChange = (_: HashChangeEvent) => {
  225. const api = excalidrawRef.current!;
  226. if (!api.ready) {
  227. return;
  228. }
  229. if (window.location.hash.length > 1) {
  230. initializeScene({
  231. resetScene: api.resetScene,
  232. initializeSocketClient: collab.initializeSocketClient,
  233. }).then((scene) => {
  234. if (scene) {
  235. api.updateScene(scene);
  236. }
  237. });
  238. }
  239. };
  240. const titleTimeout = setTimeout(
  241. () => (document.title = APP_NAME),
  242. TITLE_TIMEOUT,
  243. );
  244. window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
  245. window.addEventListener(EVENT.UNLOAD, onBlur, false);
  246. window.addEventListener(EVENT.BLUR, onBlur, false);
  247. return () => {
  248. window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
  249. window.removeEventListener(EVENT.UNLOAD, onBlur, false);
  250. window.removeEventListener(EVENT.BLUR, onBlur, false);
  251. clearTimeout(titleTimeout);
  252. };
  253. }, [collab.initializeSocketClient]);
  254. useEffect(() => {
  255. languageDetector.cacheUserLanguage(langCode);
  256. }, [langCode]);
  257. const onChange = (
  258. elements: readonly ExcalidrawElement[],
  259. appState: AppState,
  260. ) => {
  261. saveDebounced(elements, appState);
  262. if (collab.isCollaborating) {
  263. collab.broadcastElements(elements, appState);
  264. }
  265. };
  266. const onExportToBackend = async (
  267. exportedElements: readonly NonDeletedExcalidrawElement[],
  268. appState: AppState,
  269. canvas: HTMLCanvasElement | null,
  270. ) => {
  271. if (exportedElements.length === 0) {
  272. return window.alert(t("alerts.cannotExportEmptyCanvas"));
  273. }
  274. if (canvas) {
  275. try {
  276. await exportToBackend(exportedElements, {
  277. ...appState,
  278. viewBackgroundColor: appState.exportBackground
  279. ? appState.viewBackgroundColor
  280. : getDefaultAppState().viewBackgroundColor,
  281. });
  282. } catch (error) {
  283. if (error.name !== "AbortError") {
  284. const { width, height } = canvas;
  285. console.error(error, { width, height });
  286. setErrorMessage(error.message);
  287. }
  288. }
  289. }
  290. };
  291. const renderFooter = useCallback(
  292. (isMobile: boolean) => {
  293. const renderLanguageList = () => (
  294. <LanguageList
  295. onChange={(langCode) => {
  296. setLangCode(langCode);
  297. }}
  298. languages={languages}
  299. floating={!isMobile}
  300. currentLangCode={langCode}
  301. />
  302. );
  303. if (isMobile) {
  304. return (
  305. <fieldset>
  306. <legend>{t("labels.language")}</legend>
  307. {renderLanguageList()}
  308. </fieldset>
  309. );
  310. }
  311. return renderLanguageList();
  312. },
  313. [langCode],
  314. );
  315. return (
  316. <>
  317. <Excalidraw
  318. ref={excalidrawRef}
  319. onChange={onChange}
  320. width={dimensions.width}
  321. height={dimensions.height}
  322. initialData={initialStatePromiseRef.current.promise}
  323. user={{ name: collab.username }}
  324. onCollabButtonClick={collab.onCollabButtonClick}
  325. isCollaborating={collab.isCollaborating}
  326. onPointerUpdate={collab.onPointerUpdate}
  327. onExportToBackend={onExportToBackend}
  328. renderFooter={renderFooter}
  329. langCode={langCode}
  330. />
  331. {errorMessage && (
  332. <ErrorDialog
  333. message={errorMessage}
  334. onClose={() => setErrorMessage("")}
  335. />
  336. )}
  337. </>
  338. );
  339. }
  340. export default function ExcalidrawApp() {
  341. return (
  342. <TopErrorBoundary>
  343. <CollabWrapper
  344. excalidrawRef={
  345. excalidrawRef as React.MutableRefObject<ExcalidrawImperativeAPI>
  346. }
  347. >
  348. {(collab) => <ExcalidrawWrapper collab={collab} />}
  349. </CollabWrapper>
  350. </TopErrorBoundary>
  351. );
  352. }