index.tsx 9.4 KB

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