index.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693
  1. import LanguageDetector from "i18next-browser-languagedetector";
  2. import { useCallback, useContext, useEffect, useRef, useState } from "react";
  3. import { trackEvent } from "../analytics";
  4. import { getDefaultAppState } from "../appState";
  5. import { ErrorDialog } from "../components/ErrorDialog";
  6. import { TopErrorBoundary } from "../components/TopErrorBoundary";
  7. import {
  8. APP_NAME,
  9. EVENT,
  10. STORAGE_KEYS,
  11. TITLE_TIMEOUT,
  12. URL_HASH_KEYS,
  13. VERSION_TIMEOUT,
  14. } from "../constants";
  15. import { loadFromBlob } from "../data/blob";
  16. import { ImportedDataState } from "../data/types";
  17. import {
  18. ExcalidrawElement,
  19. FileId,
  20. NonDeletedExcalidrawElement,
  21. } from "../element/types";
  22. import { useCallbackRefState } from "../hooks/useCallbackRefState";
  23. import { Language, t } from "../i18n";
  24. import Excalidraw, {
  25. defaultLang,
  26. languages,
  27. } from "../packages/excalidraw/index";
  28. import {
  29. AppState,
  30. LibraryItems,
  31. ExcalidrawImperativeAPI,
  32. BinaryFileData,
  33. BinaryFiles,
  34. } from "../types";
  35. import {
  36. debounce,
  37. getVersion,
  38. preventUnload,
  39. ResolvablePromise,
  40. resolvablePromise,
  41. } from "../utils";
  42. import {
  43. FIREBASE_STORAGE_PREFIXES,
  44. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  45. } from "./app_constants";
  46. import CollabWrapper, {
  47. CollabAPI,
  48. CollabContext,
  49. CollabContextConsumer,
  50. } from "./collab/CollabWrapper";
  51. import { LanguageList } from "./components/LanguageList";
  52. import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
  53. import {
  54. importFromLocalStorage,
  55. saveToLocalStorage,
  56. } from "./data/localStorage";
  57. import CustomStats from "./CustomStats";
  58. import { restoreAppState, RestoredDataState } from "../data/restore";
  59. import { Tooltip } from "../components/Tooltip";
  60. import { shield } from "../components/icons";
  61. import "./index.scss";
  62. import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
  63. import { getMany, set, del, keys, createStore } from "idb-keyval";
  64. import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
  65. import { newElementWith } from "../element/mutateElement";
  66. import { isInitializedImageElement } from "../element/typeChecks";
  67. import { loadFilesFromFirebase } from "./data/firebase";
  68. const filesStore = createStore("files-db", "files-store");
  69. const clearObsoleteFilesFromIndexedDB = async (opts: {
  70. currentFileIds: FileId[];
  71. }) => {
  72. const allIds = await keys(filesStore);
  73. for (const id of allIds) {
  74. if (!opts.currentFileIds.includes(id as FileId)) {
  75. del(id, filesStore);
  76. }
  77. }
  78. };
  79. const localFileStorage = new FileManager({
  80. getFiles(ids) {
  81. return getMany(ids, filesStore).then(
  82. (filesData: (BinaryFileData | undefined)[]) => {
  83. const loadedFiles: BinaryFileData[] = [];
  84. const erroredFiles = new Map<FileId, true>();
  85. filesData.forEach((data, index) => {
  86. const id = ids[index];
  87. if (data) {
  88. loadedFiles.push(data);
  89. } else {
  90. erroredFiles.set(id, true);
  91. }
  92. });
  93. return { loadedFiles, erroredFiles };
  94. },
  95. );
  96. },
  97. async saveFiles({ addedFiles }) {
  98. const savedFiles = new Map<FileId, true>();
  99. const erroredFiles = new Map<FileId, true>();
  100. await Promise.all(
  101. [...addedFiles].map(async ([id, fileData]) => {
  102. try {
  103. await set(id, fileData, filesStore);
  104. savedFiles.set(id, true);
  105. } catch (error: any) {
  106. console.error(error);
  107. erroredFiles.set(id, true);
  108. }
  109. }),
  110. );
  111. return { savedFiles, erroredFiles };
  112. },
  113. });
  114. const languageDetector = new LanguageDetector();
  115. languageDetector.init({
  116. languageUtils: {
  117. formatLanguageCode: (langCode: Language["code"]) => langCode,
  118. isWhitelisted: () => true,
  119. },
  120. checkWhitelist: false,
  121. });
  122. const saveDebounced = debounce(
  123. async (
  124. elements: readonly ExcalidrawElement[],
  125. appState: AppState,
  126. files: BinaryFiles,
  127. onFilesSaved: () => void,
  128. ) => {
  129. saveToLocalStorage(elements, appState);
  130. await localFileStorage.saveFiles({
  131. elements,
  132. files,
  133. });
  134. onFilesSaved();
  135. },
  136. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  137. );
  138. const onBlur = () => {
  139. saveDebounced.flush();
  140. };
  141. const initializeScene = async (opts: {
  142. collabAPI: CollabAPI;
  143. }): Promise<
  144. { scene: ImportedDataState | null } & (
  145. | { isExternalScene: true; id: string; key: string }
  146. | { isExternalScene: false; id?: null; key?: null }
  147. )
  148. > => {
  149. const searchParams = new URLSearchParams(window.location.search);
  150. const id = searchParams.get("id");
  151. const jsonBackendMatch = window.location.hash.match(
  152. /^#json=([0-9]+),([a-zA-Z0-9_-]+)$/,
  153. );
  154. const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
  155. const localDataState = importFromLocalStorage();
  156. let scene: RestoredDataState & {
  157. scrollToContent?: boolean;
  158. } = await loadScene(null, null, localDataState);
  159. let roomLinkData = getCollaborationLinkData(window.location.href);
  160. const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
  161. if (isExternalScene) {
  162. if (
  163. // don't prompt if scene is empty
  164. !scene.elements.length ||
  165. // don't prompt for collab scenes because we don't override local storage
  166. roomLinkData ||
  167. // otherwise, prompt whether user wants to override current scene
  168. window.confirm(t("alerts.loadSceneOverridePrompt"))
  169. ) {
  170. if (jsonBackendMatch) {
  171. scene = await loadScene(
  172. jsonBackendMatch[1],
  173. jsonBackendMatch[2],
  174. localDataState,
  175. );
  176. }
  177. scene.scrollToContent = true;
  178. if (!roomLinkData) {
  179. window.history.replaceState({}, APP_NAME, window.location.origin);
  180. }
  181. } else {
  182. // https://github.com/excalidraw/excalidraw/issues/1919
  183. if (document.hidden) {
  184. return new Promise((resolve, reject) => {
  185. window.addEventListener(
  186. "focus",
  187. () => initializeScene(opts).then(resolve).catch(reject),
  188. {
  189. once: true,
  190. },
  191. );
  192. });
  193. }
  194. roomLinkData = null;
  195. window.history.replaceState({}, APP_NAME, window.location.origin);
  196. }
  197. } else if (externalUrlMatch) {
  198. window.history.replaceState({}, APP_NAME, window.location.origin);
  199. const url = externalUrlMatch[1];
  200. try {
  201. const request = await fetch(window.decodeURIComponent(url));
  202. const data = await loadFromBlob(await request.blob(), null, null);
  203. if (
  204. !scene.elements.length ||
  205. window.confirm(t("alerts.loadSceneOverridePrompt"))
  206. ) {
  207. return { scene: data, isExternalScene };
  208. }
  209. } catch (error: any) {
  210. return {
  211. scene: {
  212. appState: {
  213. errorMessage: t("alerts.invalidSceneUrl"),
  214. },
  215. },
  216. isExternalScene,
  217. };
  218. }
  219. }
  220. if (roomLinkData) {
  221. return {
  222. scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
  223. isExternalScene: true,
  224. id: roomLinkData.roomId,
  225. key: roomLinkData.roomKey,
  226. };
  227. } else if (scene) {
  228. return isExternalScene && jsonBackendMatch
  229. ? {
  230. scene,
  231. isExternalScene,
  232. id: jsonBackendMatch[1],
  233. key: jsonBackendMatch[2],
  234. }
  235. : { scene, isExternalScene: false };
  236. }
  237. return { scene: null, isExternalScene: false };
  238. };
  239. const PlusLinkJSX = (
  240. <p style={{ direction: "ltr", unicodeBidi: "embed" }}>
  241. Introducing Excalidraw+
  242. <br />
  243. <a
  244. href="https://plus.excalidraw.com/?utm_source=excalidraw&utm_medium=banner&utm_campaign=launch"
  245. target="_blank"
  246. rel="noreferrer"
  247. >
  248. Try out now!
  249. </a>
  250. </p>
  251. );
  252. const ExcalidrawWrapper = () => {
  253. const [errorMessage, setErrorMessage] = useState("");
  254. const currentLangCode = languageDetector.detect() || defaultLang.code;
  255. const [langCode, setLangCode] = useState(currentLangCode);
  256. // initial state
  257. // ---------------------------------------------------------------------------
  258. const initialStatePromiseRef = useRef<{
  259. promise: ResolvablePromise<ImportedDataState | null>;
  260. }>({ promise: null! });
  261. if (!initialStatePromiseRef.current.promise) {
  262. initialStatePromiseRef.current.promise =
  263. resolvablePromise<ImportedDataState | null>();
  264. }
  265. useEffect(() => {
  266. // Delayed so that the app has a time to load the latest SW
  267. setTimeout(() => {
  268. trackEvent("load", "version", getVersion());
  269. }, VERSION_TIMEOUT);
  270. }, []);
  271. const [excalidrawAPI, excalidrawRefCallback] =
  272. useCallbackRefState<ExcalidrawImperativeAPI>();
  273. const collabAPI = useContext(CollabContext)?.api;
  274. useEffect(() => {
  275. if (!collabAPI || !excalidrawAPI) {
  276. return;
  277. }
  278. const loadImages = (
  279. data: ResolutionType<typeof initializeScene>,
  280. isInitialLoad = false,
  281. ) => {
  282. if (!data.scene) {
  283. return;
  284. }
  285. if (collabAPI.isCollaborating()) {
  286. if (data.scene.elements) {
  287. collabAPI
  288. .fetchImageFilesFromFirebase({
  289. elements: data.scene.elements,
  290. })
  291. .then(({ loadedFiles, erroredFiles }) => {
  292. excalidrawAPI.addFiles(loadedFiles);
  293. updateStaleImageStatuses({
  294. excalidrawAPI,
  295. erroredFiles,
  296. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  297. });
  298. });
  299. }
  300. } else {
  301. const fileIds =
  302. data.scene.elements?.reduce((acc, element) => {
  303. if (isInitializedImageElement(element)) {
  304. return acc.concat(element.fileId);
  305. }
  306. return acc;
  307. }, [] as FileId[]) || [];
  308. if (data.isExternalScene) {
  309. loadFilesFromFirebase(
  310. `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
  311. data.key,
  312. fileIds,
  313. ).then(({ loadedFiles, erroredFiles }) => {
  314. excalidrawAPI.addFiles(loadedFiles);
  315. updateStaleImageStatuses({
  316. excalidrawAPI,
  317. erroredFiles,
  318. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  319. });
  320. });
  321. } else if (isInitialLoad) {
  322. if (fileIds.length) {
  323. localFileStorage
  324. .getFiles(fileIds)
  325. .then(({ loadedFiles, erroredFiles }) => {
  326. if (loadedFiles.length) {
  327. excalidrawAPI.addFiles(loadedFiles);
  328. }
  329. updateStaleImageStatuses({
  330. excalidrawAPI,
  331. erroredFiles,
  332. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  333. });
  334. });
  335. }
  336. // on fresh load, clear unused files from IDB (from previous
  337. // session)
  338. clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
  339. }
  340. }
  341. try {
  342. data.scene.libraryItems =
  343. JSON.parse(
  344. localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string,
  345. ) || [];
  346. } catch (error: any) {
  347. console.error(error);
  348. }
  349. };
  350. initializeScene({ collabAPI }).then((data) => {
  351. loadImages(data, /* isInitialLoad */ true);
  352. initialStatePromiseRef.current.promise.resolve(data.scene);
  353. });
  354. const onHashChange = (event: HashChangeEvent) => {
  355. event.preventDefault();
  356. const hash = new URLSearchParams(window.location.hash.slice(1));
  357. const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
  358. if (libraryUrl) {
  359. // If hash changed and it contains library url, import it and replace
  360. // the url to its previous state (important in case of collaboration
  361. // and similar).
  362. // Using history API won't trigger another hashchange.
  363. window.history.replaceState({}, "", event.oldURL);
  364. excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
  365. } else {
  366. initializeScene({ collabAPI }).then((data) => {
  367. loadImages(data);
  368. if (data.scene) {
  369. excalidrawAPI.updateScene({
  370. ...data.scene,
  371. appState: restoreAppState(data.scene.appState, null),
  372. });
  373. }
  374. });
  375. }
  376. };
  377. const titleTimeout = setTimeout(
  378. () => (document.title = APP_NAME),
  379. TITLE_TIMEOUT,
  380. );
  381. window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
  382. window.addEventListener(EVENT.UNLOAD, onBlur, false);
  383. window.addEventListener(EVENT.BLUR, onBlur, false);
  384. return () => {
  385. window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
  386. window.removeEventListener(EVENT.UNLOAD, onBlur, false);
  387. window.removeEventListener(EVENT.BLUR, onBlur, false);
  388. clearTimeout(titleTimeout);
  389. };
  390. }, [collabAPI, excalidrawAPI]);
  391. useEffect(() => {
  392. const unloadHandler = (event: BeforeUnloadEvent) => {
  393. saveDebounced.flush();
  394. if (
  395. excalidrawAPI &&
  396. localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
  397. ) {
  398. preventUnload(event);
  399. }
  400. };
  401. window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  402. return () => {
  403. window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  404. };
  405. }, [excalidrawAPI]);
  406. useEffect(() => {
  407. languageDetector.cacheUserLanguage(langCode);
  408. }, [langCode]);
  409. const onChange = (
  410. elements: readonly ExcalidrawElement[],
  411. appState: AppState,
  412. files: BinaryFiles,
  413. ) => {
  414. if (collabAPI?.isCollaborating()) {
  415. collabAPI.broadcastElements(elements);
  416. } else {
  417. saveDebounced(elements, appState, files, () => {
  418. if (excalidrawAPI) {
  419. let didChange = false;
  420. let pendingImageElement = appState.pendingImageElement;
  421. const elements = excalidrawAPI
  422. .getSceneElementsIncludingDeleted()
  423. .map((element) => {
  424. if (localFileStorage.shouldUpdateImageElementStatus(element)) {
  425. didChange = true;
  426. const newEl = newElementWith(element, { status: "saved" });
  427. if (pendingImageElement === element) {
  428. pendingImageElement = newEl;
  429. }
  430. return newEl;
  431. }
  432. return element;
  433. });
  434. if (didChange) {
  435. excalidrawAPI.updateScene({
  436. elements,
  437. appState: {
  438. pendingImageElement,
  439. },
  440. });
  441. }
  442. }
  443. });
  444. }
  445. };
  446. const onExportToBackend = async (
  447. exportedElements: readonly NonDeletedExcalidrawElement[],
  448. appState: AppState,
  449. files: BinaryFiles,
  450. canvas: HTMLCanvasElement | null,
  451. ) => {
  452. if (exportedElements.length === 0) {
  453. return window.alert(t("alerts.cannotExportEmptyCanvas"));
  454. }
  455. if (canvas) {
  456. try {
  457. await exportToBackend(
  458. exportedElements,
  459. {
  460. ...appState,
  461. viewBackgroundColor: appState.exportBackground
  462. ? appState.viewBackgroundColor
  463. : getDefaultAppState().viewBackgroundColor,
  464. },
  465. files,
  466. );
  467. } catch (error: any) {
  468. if (error.name !== "AbortError") {
  469. const { width, height } = canvas;
  470. console.error(error, { width, height });
  471. setErrorMessage(error.message);
  472. }
  473. }
  474. }
  475. };
  476. const renderTopRightUI = useCallback(
  477. (isMobile: boolean, appState: AppState) => {
  478. if (isMobile) {
  479. return null;
  480. }
  481. return (
  482. <div
  483. style={{
  484. width: "24ch",
  485. fontSize: "0.7em",
  486. textAlign: "center",
  487. }}
  488. >
  489. {/* <GitHubCorner theme={appState.theme} dir={document.dir} /> */}
  490. {/* FIXME remove after 2021-05-20 */}
  491. {PlusLinkJSX}
  492. </div>
  493. );
  494. },
  495. [],
  496. );
  497. const renderFooter = useCallback(
  498. (isMobile: boolean) => {
  499. const renderEncryptedIcon = () => (
  500. <a
  501. className="encrypted-icon tooltip"
  502. href="https://blog.excalidraw.com/end-to-end-encryption/"
  503. target="_blank"
  504. rel="noopener noreferrer"
  505. aria-label={t("encrypted.link")}
  506. >
  507. <Tooltip label={t("encrypted.tooltip")} long={true}>
  508. {shield}
  509. </Tooltip>
  510. </a>
  511. );
  512. const renderLanguageList = () => (
  513. <LanguageList
  514. onChange={(langCode) => setLangCode(langCode)}
  515. languages={languages}
  516. currentLangCode={langCode}
  517. />
  518. );
  519. if (isMobile) {
  520. const isTinyDevice = window.innerWidth < 362;
  521. return (
  522. <div
  523. style={{
  524. display: "flex",
  525. flexDirection: isTinyDevice ? "column" : "row",
  526. }}
  527. >
  528. <fieldset>
  529. <legend>{t("labels.language")}</legend>
  530. {renderLanguageList()}
  531. </fieldset>
  532. {/* FIXME remove after 2021-05-20 */}
  533. <div
  534. style={{
  535. width: "24ch",
  536. fontSize: "0.7em",
  537. textAlign: "center",
  538. marginTop: isTinyDevice ? 16 : undefined,
  539. marginLeft: "auto",
  540. marginRight: isTinyDevice ? "auto" : undefined,
  541. padding: "4px 2px",
  542. border: "1px dashed #aaa",
  543. borderRadius: 12,
  544. }}
  545. >
  546. {PlusLinkJSX}
  547. </div>
  548. </div>
  549. );
  550. }
  551. return (
  552. <>
  553. {renderEncryptedIcon()}
  554. {renderLanguageList()}
  555. </>
  556. );
  557. },
  558. [langCode],
  559. );
  560. const renderCustomStats = () => {
  561. return (
  562. <CustomStats
  563. setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
  564. />
  565. );
  566. };
  567. const onLibraryChange = async (items: LibraryItems) => {
  568. if (!items.length) {
  569. localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
  570. return;
  571. }
  572. const serializedItems = JSON.stringify(items);
  573. localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
  574. };
  575. const onRoomClose = useCallback(() => {
  576. localFileStorage.reset();
  577. }, []);
  578. return (
  579. <>
  580. <Excalidraw
  581. ref={excalidrawRefCallback}
  582. onChange={onChange}
  583. initialData={initialStatePromiseRef.current.promise}
  584. onCollabButtonClick={collabAPI?.onCollabButtonClick}
  585. isCollaborating={collabAPI?.isCollaborating()}
  586. onPointerUpdate={collabAPI?.onPointerUpdate}
  587. UIOptions={{
  588. canvasActions: {
  589. export: {
  590. onExportToBackend,
  591. renderCustomUI: (elements, appState, files) => {
  592. return (
  593. <ExportToExcalidrawPlus
  594. elements={elements}
  595. appState={appState}
  596. files={files}
  597. onError={(error) => {
  598. excalidrawAPI?.updateScene({
  599. appState: {
  600. errorMessage: error.message,
  601. },
  602. });
  603. }}
  604. />
  605. );
  606. },
  607. },
  608. },
  609. }}
  610. renderTopRightUI={renderTopRightUI}
  611. renderFooter={renderFooter}
  612. langCode={langCode}
  613. renderCustomStats={renderCustomStats}
  614. detectScroll={false}
  615. handleKeyboardGlobally={true}
  616. onLibraryChange={onLibraryChange}
  617. autoFocus={true}
  618. />
  619. {excalidrawAPI && (
  620. <CollabWrapper
  621. excalidrawAPI={excalidrawAPI}
  622. onRoomClose={onRoomClose}
  623. />
  624. )}
  625. {errorMessage && (
  626. <ErrorDialog
  627. message={errorMessage}
  628. onClose={() => setErrorMessage("")}
  629. />
  630. )}
  631. </>
  632. );
  633. };
  634. const ExcalidrawApp = () => {
  635. return (
  636. <TopErrorBoundary>
  637. <CollabContextConsumer>
  638. <ExcalidrawWrapper />
  639. </CollabContextConsumer>
  640. </TopErrorBoundary>
  641. );
  642. };
  643. export default ExcalidrawApp;