index.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463
  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 { restoreAppState, RestoredDataState } from "../data/restore";
  55. import { Tooltip } from "../components/Tooltip";
  56. import { shield } from "../components/icons";
  57. import "./index.scss";
  58. const languageDetector = new LanguageDetector();
  59. languageDetector.init({
  60. languageUtils: {
  61. formatLanguageCode: (langCode: Language["code"]) => langCode,
  62. isWhitelisted: () => true,
  63. },
  64. checkWhitelist: false,
  65. });
  66. const saveDebounced = debounce(
  67. (elements: readonly ExcalidrawElement[], state: AppState) => {
  68. saveToLocalStorage(elements, state);
  69. },
  70. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  71. );
  72. const onBlur = () => {
  73. saveDebounced.flush();
  74. };
  75. const initializeScene = async (opts: {
  76. collabAPI: CollabAPI;
  77. }): Promise<ImportedDataState | null> => {
  78. const searchParams = new URLSearchParams(window.location.search);
  79. const id = searchParams.get("id");
  80. const jsonBackendMatch = window.location.hash.match(
  81. /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
  82. );
  83. const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
  84. const localDataState = importFromLocalStorage();
  85. let scene: RestoredDataState & {
  86. scrollToContent?: boolean;
  87. } = await loadScene(null, null, localDataState);
  88. let roomLinkData = getCollaborationLinkData(window.location.href);
  89. const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
  90. if (isExternalScene) {
  91. if (
  92. // don't prompt if scene is empty
  93. !scene.elements.length ||
  94. // don't prompt for collab scenes because we don't override local storage
  95. roomLinkData ||
  96. // otherwise, prompt whether user wants to override current scene
  97. window.confirm(t("alerts.loadSceneOverridePrompt"))
  98. ) {
  99. // Backwards compatibility with legacy url format
  100. if (id) {
  101. scene = await loadScene(id, null, localDataState);
  102. } else if (jsonBackendMatch) {
  103. scene = await loadScene(
  104. jsonBackendMatch[1],
  105. jsonBackendMatch[2],
  106. localDataState,
  107. );
  108. }
  109. scene.scrollToContent = true;
  110. if (!roomLinkData) {
  111. window.history.replaceState({}, APP_NAME, window.location.origin);
  112. }
  113. } else {
  114. // https://github.com/excalidraw/excalidraw/issues/1919
  115. if (document.hidden) {
  116. return new Promise((resolve, reject) => {
  117. window.addEventListener(
  118. "focus",
  119. () => initializeScene(opts).then(resolve).catch(reject),
  120. {
  121. once: true,
  122. },
  123. );
  124. });
  125. }
  126. roomLinkData = null;
  127. window.history.replaceState({}, APP_NAME, window.location.origin);
  128. }
  129. } else if (externalUrlMatch) {
  130. window.history.replaceState({}, APP_NAME, window.location.origin);
  131. const url = externalUrlMatch[1];
  132. try {
  133. const request = await fetch(window.decodeURIComponent(url));
  134. const data = await loadFromBlob(await request.blob(), null);
  135. if (
  136. !scene.elements.length ||
  137. window.confirm(t("alerts.loadSceneOverridePrompt"))
  138. ) {
  139. return data;
  140. }
  141. } catch (error) {
  142. return {
  143. appState: {
  144. errorMessage: t("alerts.invalidSceneUrl"),
  145. },
  146. };
  147. }
  148. }
  149. if (roomLinkData) {
  150. return opts.collabAPI.initializeSocketClient(roomLinkData);
  151. } else if (scene) {
  152. return scene;
  153. }
  154. return null;
  155. };
  156. const PlusLinkJSX = (
  157. <p style={{ direction: "ltr", unicodeBidi: "embed" }}>
  158. Introducing Excalidraw+
  159. <br />
  160. <a
  161. href="https://plus.excalidraw.com/?utm_source=excalidraw&utm_medium=banner&utm_campaign=launch"
  162. target="_blank"
  163. rel="noreferrer"
  164. >
  165. Try out now!
  166. </a>
  167. </p>
  168. );
  169. const ExcalidrawWrapper = () => {
  170. const [errorMessage, setErrorMessage] = useState("");
  171. const currentLangCode = languageDetector.detect() || defaultLang.code;
  172. const [langCode, setLangCode] = useState(currentLangCode);
  173. // initial state
  174. // ---------------------------------------------------------------------------
  175. const initialStatePromiseRef = useRef<{
  176. promise: ResolvablePromise<ImportedDataState | null>;
  177. }>({ promise: null! });
  178. if (!initialStatePromiseRef.current.promise) {
  179. initialStatePromiseRef.current.promise = resolvablePromise<ImportedDataState | null>();
  180. }
  181. useEffect(() => {
  182. // Delayed so that the app has a time to load the latest SW
  183. setTimeout(() => {
  184. trackEvent("load", "version", getVersion());
  185. }, VERSION_TIMEOUT);
  186. }, []);
  187. const [
  188. excalidrawAPI,
  189. excalidrawRefCallback,
  190. ] = useCallbackRefState<ExcalidrawImperativeAPI>();
  191. const collabAPI = useContext(CollabContext)?.api;
  192. useEffect(() => {
  193. if (!collabAPI || !excalidrawAPI) {
  194. return;
  195. }
  196. initializeScene({ collabAPI }).then((scene) => {
  197. if (scene) {
  198. try {
  199. scene.libraryItems =
  200. JSON.parse(
  201. localStorage.getItem(
  202. STORAGE_KEYS.LOCAL_STORAGE_LIBRARY,
  203. ) as string,
  204. ) || [];
  205. } catch (e) {
  206. console.error(e);
  207. }
  208. }
  209. initialStatePromiseRef.current.promise.resolve(scene);
  210. });
  211. const onHashChange = (event: HashChangeEvent) => {
  212. event.preventDefault();
  213. const hash = new URLSearchParams(window.location.hash.slice(1));
  214. const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
  215. if (libraryUrl) {
  216. // If hash changed and it contains library url, import it and replace
  217. // the url to its previous state (important in case of collaboration
  218. // and similar).
  219. // Using history API won't trigger another hashchange.
  220. window.history.replaceState({}, "", event.oldURL);
  221. excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
  222. } else {
  223. initializeScene({ collabAPI }).then((scene) => {
  224. if (scene) {
  225. excalidrawAPI.updateScene({
  226. ...scene,
  227. appState: restoreAppState(scene.appState, null),
  228. });
  229. }
  230. });
  231. }
  232. };
  233. const titleTimeout = setTimeout(
  234. () => (document.title = APP_NAME),
  235. TITLE_TIMEOUT,
  236. );
  237. window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
  238. window.addEventListener(EVENT.UNLOAD, onBlur, false);
  239. window.addEventListener(EVENT.BLUR, onBlur, false);
  240. return () => {
  241. window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
  242. window.removeEventListener(EVENT.UNLOAD, onBlur, false);
  243. window.removeEventListener(EVENT.BLUR, onBlur, false);
  244. clearTimeout(titleTimeout);
  245. };
  246. }, [collabAPI, excalidrawAPI]);
  247. useEffect(() => {
  248. languageDetector.cacheUserLanguage(langCode);
  249. }, [langCode]);
  250. const onChange = (
  251. elements: readonly ExcalidrawElement[],
  252. appState: AppState,
  253. ) => {
  254. if (collabAPI?.isCollaborating()) {
  255. collabAPI.broadcastElements(elements);
  256. } else {
  257. // collab scenes are persisted to the server, so we don't have to persist
  258. // them locally, which has the added benefit of not overwriting whatever
  259. // the user was working on before joining
  260. saveDebounced(elements, appState);
  261. }
  262. };
  263. const onExportToBackend = async (
  264. exportedElements: readonly NonDeletedExcalidrawElement[],
  265. appState: AppState,
  266. canvas: HTMLCanvasElement | null,
  267. ) => {
  268. if (exportedElements.length === 0) {
  269. return window.alert(t("alerts.cannotExportEmptyCanvas"));
  270. }
  271. if (canvas) {
  272. try {
  273. await exportToBackend(exportedElements, {
  274. ...appState,
  275. viewBackgroundColor: appState.exportBackground
  276. ? appState.viewBackgroundColor
  277. : getDefaultAppState().viewBackgroundColor,
  278. });
  279. } catch (error) {
  280. if (error.name !== "AbortError") {
  281. const { width, height } = canvas;
  282. console.error(error, { width, height });
  283. setErrorMessage(error.message);
  284. }
  285. }
  286. }
  287. };
  288. const renderTopRightUI = useCallback(
  289. (isMobile: boolean, appState: AppState) => {
  290. return (
  291. <div
  292. style={{
  293. width: "24ch",
  294. fontSize: "0.7em",
  295. textAlign: "center",
  296. }}
  297. >
  298. {/* <GitHubCorner theme={appState.theme} dir={document.dir} /> */}
  299. {/* FIXME remove after 2021-05-20 */}
  300. {PlusLinkJSX}
  301. </div>
  302. );
  303. },
  304. [],
  305. );
  306. const renderFooter = useCallback(
  307. (isMobile: boolean) => {
  308. const renderEncryptedIcon = () => (
  309. <a
  310. className="encrypted-icon tooltip"
  311. href="https://blog.excalidraw.com/end-to-end-encryption/"
  312. target="_blank"
  313. rel="noopener noreferrer"
  314. aria-label={t("encrypted.link")}
  315. >
  316. <Tooltip label={t("encrypted.tooltip")} long={true}>
  317. {shield}
  318. </Tooltip>
  319. </a>
  320. );
  321. const renderLanguageList = () => (
  322. <LanguageList
  323. onChange={(langCode) => {
  324. setLangCode(langCode);
  325. }}
  326. languages={languages}
  327. floating={!isMobile}
  328. currentLangCode={langCode}
  329. />
  330. );
  331. if (isMobile) {
  332. const isTinyDevice = window.innerWidth < 362;
  333. return (
  334. <div
  335. style={{
  336. display: "flex",
  337. flexDirection: isTinyDevice ? "column" : "row",
  338. }}
  339. >
  340. <fieldset>
  341. <legend>{t("labels.language")}</legend>
  342. {renderLanguageList()}
  343. </fieldset>
  344. {/* FIXME remove after 2021-05-20 */}
  345. <div
  346. style={{
  347. width: "24ch",
  348. fontSize: "0.7em",
  349. textAlign: "center",
  350. marginTop: isTinyDevice ? 16 : undefined,
  351. marginLeft: "auto",
  352. marginRight: isTinyDevice ? "auto" : undefined,
  353. padding: "4px 2px",
  354. border: "1px dashed #aaa",
  355. borderRadius: 12,
  356. }}
  357. >
  358. {PlusLinkJSX}
  359. </div>
  360. </div>
  361. );
  362. }
  363. return (
  364. <>
  365. {renderEncryptedIcon()}
  366. {renderLanguageList()}
  367. </>
  368. );
  369. },
  370. [langCode],
  371. );
  372. const renderCustomStats = () => {
  373. return (
  374. <CustomStats
  375. setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
  376. />
  377. );
  378. };
  379. const onLibraryChange = async (items: LibraryItems) => {
  380. if (!items.length) {
  381. localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
  382. return;
  383. }
  384. const serializedItems = JSON.stringify(items);
  385. localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
  386. };
  387. return (
  388. <>
  389. <Excalidraw
  390. ref={excalidrawRefCallback}
  391. onChange={onChange}
  392. initialData={initialStatePromiseRef.current.promise}
  393. onCollabButtonClick={collabAPI?.onCollabButtonClick}
  394. isCollaborating={collabAPI?.isCollaborating()}
  395. onPointerUpdate={collabAPI?.onPointerUpdate}
  396. UIOptions={{
  397. canvasActions: {
  398. export: {
  399. onExportToBackend,
  400. },
  401. },
  402. }}
  403. renderTopRightUI={renderTopRightUI}
  404. renderFooter={renderFooter}
  405. langCode={langCode}
  406. renderCustomStats={renderCustomStats}
  407. detectScroll={false}
  408. handleKeyboardGlobally={true}
  409. onLibraryChange={onLibraryChange}
  410. />
  411. {excalidrawAPI && <CollabWrapper excalidrawAPI={excalidrawAPI} />}
  412. {errorMessage && (
  413. <ErrorDialog
  414. message={errorMessage}
  415. onClose={() => setErrorMessage("")}
  416. />
  417. )}
  418. </>
  419. );
  420. };
  421. const ExcalidrawApp = () => {
  422. return (
  423. <TopErrorBoundary>
  424. <CollabContextConsumer>
  425. <ExcalidrawWrapper />
  426. </CollabContextConsumer>
  427. </TopErrorBoundary>
  428. );
  429. };
  430. export default ExcalidrawApp;