123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715 |
- import LanguageDetector from "i18next-browser-languagedetector";
- import { useCallback, useContext, useEffect, useRef, useState } from "react";
- import { trackEvent } from "../analytics";
- import { getDefaultAppState } from "../appState";
- import { ErrorDialog } from "../components/ErrorDialog";
- import { TopErrorBoundary } from "../components/TopErrorBoundary";
- import {
- APP_NAME,
- COOKIES,
- EVENT,
- TITLE_TIMEOUT,
- VERSION_TIMEOUT,
- } from "../constants";
- import { loadFromBlob } from "../data/blob";
- import {
- ExcalidrawElement,
- FileId,
- NonDeletedExcalidrawElement,
- } from "../element/types";
- import { useCallbackRefState } from "../hooks/useCallbackRefState";
- import { t } from "../i18n";
- import {
- Excalidraw,
- defaultLang,
- languages,
- } from "../packages/excalidraw/index";
- import {
- AppState,
- LibraryItems,
- ExcalidrawImperativeAPI,
- BinaryFiles,
- ExcalidrawInitialDataState,
- } from "../types";
- import {
- debounce,
- getVersion,
- getFrame,
- isTestEnv,
- preventUnload,
- ResolvablePromise,
- resolvablePromise,
- } from "../utils";
- import {
- FIREBASE_STORAGE_PREFIXES,
- STORAGE_KEYS,
- SYNC_BROWSER_TABS_TIMEOUT,
- } from "./app_constants";
- import CollabWrapper, {
- CollabAPI,
- CollabContext,
- CollabContextConsumer,
- } from "./collab/CollabWrapper";
- import { LanguageList } from "./components/LanguageList";
- import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
- import {
- getLibraryItemsFromStorage,
- importFromLocalStorage,
- importUsernameFromLocalStorage,
- } from "./data/localStorage";
- import CustomStats from "./CustomStats";
- import { restoreAppState, RestoredDataState } from "../data/restore";
- import { Tooltip } from "../components/Tooltip";
- import { shield } from "../components/icons";
- import "./index.scss";
- import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
- import { updateStaleImageStatuses } from "./data/FileManager";
- import { newElementWith } from "../element/mutateElement";
- import { isInitializedImageElement } from "../element/typeChecks";
- import { loadFilesFromFirebase } from "./data/firebase";
- import { LocalData } from "./data/LocalData";
- import { isBrowserStorageStateNewer } from "./data/tabSync";
- import clsx from "clsx";
- import { parseLibraryTokensFromUrl, useHandleLibrary } from "../data/library";
- const isExcalidrawPlusSignedUser = document.cookie.includes(
- COOKIES.AUTH_STATE_COOKIE,
- );
- const languageDetector = new LanguageDetector();
- languageDetector.init({
- languageUtils: {},
- });
- const initializeScene = async (opts: {
- collabAPI: CollabAPI;
- }): Promise<
- { scene: ExcalidrawInitialDataState | null } & (
- | { isExternalScene: true; id: string; key: string }
- | { isExternalScene: false; id?: null; key?: null }
- )
- > => {
- const searchParams = new URLSearchParams(window.location.search);
- const id = searchParams.get("id");
- const jsonBackendMatch = window.location.hash.match(
- /^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
- );
- const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
- const localDataState = importFromLocalStorage();
- let scene: RestoredDataState & {
- scrollToContent?: boolean;
- } = await loadScene(null, null, localDataState);
- let roomLinkData = getCollaborationLinkData(window.location.href);
- const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
- if (isExternalScene) {
- if (
- // don't prompt if scene is empty
- !scene.elements.length ||
- // don't prompt for collab scenes because we don't override local storage
- roomLinkData ||
- // otherwise, prompt whether user wants to override current scene
- window.confirm(t("alerts.loadSceneOverridePrompt"))
- ) {
- if (jsonBackendMatch) {
- scene = await loadScene(
- jsonBackendMatch[1],
- jsonBackendMatch[2],
- localDataState,
- );
- }
- scene.scrollToContent = true;
- if (!roomLinkData) {
- window.history.replaceState({}, APP_NAME, window.location.origin);
- }
- } else {
- // https://github.com/excalidraw/excalidraw/issues/1919
- if (document.hidden) {
- return new Promise((resolve, reject) => {
- window.addEventListener(
- "focus",
- () => initializeScene(opts).then(resolve).catch(reject),
- {
- once: true,
- },
- );
- });
- }
- roomLinkData = null;
- window.history.replaceState({}, APP_NAME, window.location.origin);
- }
- } else if (externalUrlMatch) {
- window.history.replaceState({}, APP_NAME, window.location.origin);
- const url = externalUrlMatch[1];
- try {
- const request = await fetch(window.decodeURIComponent(url));
- const data = await loadFromBlob(await request.blob(), null, null);
- if (
- !scene.elements.length ||
- window.confirm(t("alerts.loadSceneOverridePrompt"))
- ) {
- return { scene: data, isExternalScene };
- }
- } catch (error: any) {
- return {
- scene: {
- appState: {
- errorMessage: t("alerts.invalidSceneUrl"),
- },
- },
- isExternalScene,
- };
- }
- }
- if (roomLinkData) {
- return {
- scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
- isExternalScene: true,
- id: roomLinkData.roomId,
- key: roomLinkData.roomKey,
- };
- } else if (scene) {
- return isExternalScene && jsonBackendMatch
- ? {
- scene,
- isExternalScene,
- id: jsonBackendMatch[1],
- key: jsonBackendMatch[2],
- }
- : { scene, isExternalScene: false };
- }
- return { scene: null, isExternalScene: false };
- };
- const PlusLPLinkJSX = (
- <p style={{ direction: "ltr", unicodeBidi: "embed" }}>
- Introducing Excalidraw+
- <br />
- <a
- href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=banner&utm_campaign=launch"
- target="_blank"
- rel="noreferrer"
- >
- Try out now!
- </a>
- </p>
- );
- const PlusAppLinkJSX = (
- <a
- href={`${process.env.REACT_APP_PLUS_APP}/#excalidraw-redirect`}
- target="_blank"
- rel="noreferrer"
- className="plus-button"
- >
- Go to Excalidraw+
- </a>
- );
- const ExcalidrawWrapper = () => {
- const [errorMessage, setErrorMessage] = useState("");
- let currentLangCode = languageDetector.detect() || defaultLang.code;
- if (Array.isArray(currentLangCode)) {
- currentLangCode = currentLangCode[0];
- }
- const [langCode, setLangCode] = useState(currentLangCode);
- // initial state
- // ---------------------------------------------------------------------------
- const initialStatePromiseRef = useRef<{
- promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
- }>({ promise: null! });
- if (!initialStatePromiseRef.current.promise) {
- initialStatePromiseRef.current.promise =
- resolvablePromise<ExcalidrawInitialDataState | null>();
- }
- useEffect(() => {
- trackEvent("load", "frame", getFrame());
- // Delayed so that the app has a time to load the latest SW
- setTimeout(() => {
- trackEvent("load", "version", getVersion());
- }, VERSION_TIMEOUT);
- }, []);
- const [excalidrawAPI, excalidrawRefCallback] =
- useCallbackRefState<ExcalidrawImperativeAPI>();
- const collabAPI = useContext(CollabContext)?.api;
- useHandleLibrary({
- excalidrawAPI,
- getInitialLibraryItems: getLibraryItemsFromStorage,
- });
- useEffect(() => {
- if (!collabAPI || !excalidrawAPI) {
- return;
- }
- const loadImages = (
- data: ResolutionType<typeof initializeScene>,
- isInitialLoad = false,
- ) => {
- if (!data.scene) {
- return;
- }
- if (collabAPI.isCollaborating()) {
- if (data.scene.elements) {
- collabAPI
- .fetchImageFilesFromFirebase({
- elements: data.scene.elements,
- })
- .then(({ loadedFiles, erroredFiles }) => {
- excalidrawAPI.addFiles(loadedFiles);
- updateStaleImageStatuses({
- excalidrawAPI,
- erroredFiles,
- elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
- });
- });
- }
- } else {
- const fileIds =
- data.scene.elements?.reduce((acc, element) => {
- if (isInitializedImageElement(element)) {
- return acc.concat(element.fileId);
- }
- return acc;
- }, [] as FileId[]) || [];
- if (data.isExternalScene) {
- loadFilesFromFirebase(
- `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
- data.key,
- fileIds,
- ).then(({ loadedFiles, erroredFiles }) => {
- excalidrawAPI.addFiles(loadedFiles);
- updateStaleImageStatuses({
- excalidrawAPI,
- erroredFiles,
- elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
- });
- });
- } else if (isInitialLoad) {
- if (fileIds.length) {
- LocalData.fileStorage
- .getFiles(fileIds)
- .then(({ loadedFiles, erroredFiles }) => {
- if (loadedFiles.length) {
- excalidrawAPI.addFiles(loadedFiles);
- }
- updateStaleImageStatuses({
- excalidrawAPI,
- erroredFiles,
- elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
- });
- });
- }
- // on fresh load, clear unused files from IDB (from previous
- // session)
- LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
- }
- }
- };
- initializeScene({ collabAPI }).then((data) => {
- loadImages(data, /* isInitialLoad */ true);
- initialStatePromiseRef.current.promise.resolve(data.scene);
- });
- const onHashChange = async (event: HashChangeEvent) => {
- event.preventDefault();
- const libraryUrlTokens = parseLibraryTokensFromUrl();
- if (!libraryUrlTokens) {
- initializeScene({ collabAPI }).then((data) => {
- loadImages(data);
- if (data.scene) {
- excalidrawAPI.updateScene({
- ...data.scene,
- appState: restoreAppState(data.scene.appState, null),
- });
- }
- });
- }
- };
- const titleTimeout = setTimeout(
- () => (document.title = APP_NAME),
- TITLE_TIMEOUT,
- );
- const syncData = debounce(() => {
- if (isTestEnv()) {
- return;
- }
- if (!document.hidden && !collabAPI.isCollaborating()) {
- // don't sync if local state is newer or identical to browser state
- if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
- const localDataState = importFromLocalStorage();
- const username = importUsernameFromLocalStorage();
- let langCode = languageDetector.detect() || defaultLang.code;
- if (Array.isArray(langCode)) {
- langCode = langCode[0];
- }
- setLangCode(langCode);
- excalidrawAPI.updateScene({
- ...localDataState,
- });
- excalidrawAPI.updateLibrary({
- libraryItems: getLibraryItemsFromStorage(),
- });
- collabAPI.setUsername(username || "");
- }
- if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
- const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
- const currFiles = excalidrawAPI.getFiles();
- const fileIds =
- elements?.reduce((acc, element) => {
- if (
- isInitializedImageElement(element) &&
- // only load and update images that aren't already loaded
- !currFiles[element.fileId]
- ) {
- return acc.concat(element.fileId);
- }
- return acc;
- }, [] as FileId[]) || [];
- if (fileIds.length) {
- LocalData.fileStorage
- .getFiles(fileIds)
- .then(({ loadedFiles, erroredFiles }) => {
- if (loadedFiles.length) {
- excalidrawAPI.addFiles(loadedFiles);
- }
- updateStaleImageStatuses({
- excalidrawAPI,
- erroredFiles,
- elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
- });
- });
- }
- }
- }
- }, SYNC_BROWSER_TABS_TIMEOUT);
- const onUnload = () => {
- LocalData.flushSave();
- };
- const visibilityChange = (event: FocusEvent | Event) => {
- if (event.type === EVENT.BLUR || document.hidden) {
- LocalData.flushSave();
- }
- if (
- event.type === EVENT.VISIBILITY_CHANGE ||
- event.type === EVENT.FOCUS
- ) {
- syncData();
- }
- };
- window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
- window.addEventListener(EVENT.UNLOAD, onUnload, false);
- window.addEventListener(EVENT.BLUR, visibilityChange, false);
- document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
- window.addEventListener(EVENT.FOCUS, visibilityChange, false);
- return () => {
- window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
- window.removeEventListener(EVENT.UNLOAD, onUnload, false);
- window.removeEventListener(EVENT.BLUR, visibilityChange, false);
- window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
- document.removeEventListener(
- EVENT.VISIBILITY_CHANGE,
- visibilityChange,
- false,
- );
- clearTimeout(titleTimeout);
- };
- }, [collabAPI, excalidrawAPI]);
- useEffect(() => {
- const unloadHandler = (event: BeforeUnloadEvent) => {
- LocalData.flushSave();
- if (
- excalidrawAPI &&
- LocalData.fileStorage.shouldPreventUnload(
- excalidrawAPI.getSceneElements(),
- )
- ) {
- preventUnload(event);
- }
- };
- window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
- return () => {
- window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
- };
- }, [excalidrawAPI]);
- useEffect(() => {
- languageDetector.cacheUserLanguage(langCode);
- }, [langCode]);
- const onChange = (
- elements: readonly ExcalidrawElement[],
- appState: AppState,
- files: BinaryFiles,
- ) => {
- if (collabAPI?.isCollaborating()) {
- collabAPI.syncElements(elements);
- }
- // this check is redundant, but since this is a hot path, it's best
- // not to evaludate the nested expression every time
- if (!LocalData.isSavePaused()) {
- LocalData.save(elements, appState, files, () => {
- if (excalidrawAPI) {
- let didChange = false;
- const elements = excalidrawAPI
- .getSceneElementsIncludingDeleted()
- .map((element) => {
- if (
- LocalData.fileStorage.shouldUpdateImageElementStatus(element)
- ) {
- const newElement = newElementWith(element, { status: "saved" });
- if (newElement !== element) {
- didChange = true;
- }
- return newElement;
- }
- return element;
- });
- if (didChange) {
- excalidrawAPI.updateScene({
- elements,
- });
- }
- }
- });
- }
- };
- const onExportToBackend = async (
- exportedElements: readonly NonDeletedExcalidrawElement[],
- appState: AppState,
- files: BinaryFiles,
- canvas: HTMLCanvasElement | null,
- ) => {
- if (exportedElements.length === 0) {
- return window.alert(t("alerts.cannotExportEmptyCanvas"));
- }
- if (canvas) {
- try {
- await exportToBackend(
- exportedElements,
- {
- ...appState,
- viewBackgroundColor: appState.exportBackground
- ? appState.viewBackgroundColor
- : getDefaultAppState().viewBackgroundColor,
- },
- files,
- );
- } catch (error: any) {
- if (error.name !== "AbortError") {
- const { width, height } = canvas;
- console.error(error, { width, height });
- setErrorMessage(error.message);
- }
- }
- }
- };
- const renderTopRightUI = useCallback(
- (isMobile: boolean, appState: AppState) => {
- if (isMobile) {
- return null;
- }
- return (
- <div
- style={{
- width: isExcalidrawPlusSignedUser ? "21ch" : "23ch",
- fontSize: "0.7em",
- textAlign: "center",
- }}
- >
- {isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
- </div>
- );
- },
- [],
- );
- const renderFooter = useCallback(
- (isMobile: boolean) => {
- const renderEncryptedIcon = () => (
- <a
- className="encrypted-icon tooltip"
- href="https://blog.excalidraw.com/end-to-end-encryption/"
- target="_blank"
- rel="noopener noreferrer"
- aria-label={t("encrypted.link")}
- >
- <Tooltip label={t("encrypted.tooltip")} long={true}>
- {shield}
- </Tooltip>
- </a>
- );
- const renderLanguageList = () => (
- <LanguageList
- onChange={(langCode) => setLangCode(langCode)}
- languages={languages}
- currentLangCode={langCode}
- />
- );
- if (isMobile) {
- const isTinyDevice = window.innerWidth < 362;
- return (
- <div
- style={{
- display: "flex",
- flexDirection: isTinyDevice ? "column" : "row",
- }}
- >
- <fieldset>
- <legend>{t("labels.language")}</legend>
- {renderLanguageList()}
- </fieldset>
- {/* FIXME remove after 2021-05-20 */}
- <div
- style={{
- width: "24ch",
- fontSize: "0.7em",
- textAlign: "center",
- marginTop: isTinyDevice ? 16 : undefined,
- marginLeft: "auto",
- marginRight: isTinyDevice ? "auto" : undefined,
- padding: isExcalidrawPlusSignedUser ? undefined : "4px 2px",
- border: isExcalidrawPlusSignedUser
- ? undefined
- : "1px dashed #aaa",
- borderRadius: 12,
- }}
- >
- {isExcalidrawPlusSignedUser ? PlusAppLinkJSX : PlusLPLinkJSX}
- </div>
- </div>
- );
- }
- return (
- <>
- {renderEncryptedIcon()}
- {renderLanguageList()}
- </>
- );
- },
- [langCode],
- );
- const renderCustomStats = () => {
- return (
- <CustomStats
- setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
- />
- );
- };
- const onLibraryChange = async (items: LibraryItems) => {
- if (!items.length) {
- localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
- return;
- }
- const serializedItems = JSON.stringify(items);
- localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
- };
- const onRoomClose = useCallback(() => {
- LocalData.fileStorage.reset();
- }, []);
- return (
- <div
- style={{ height: "100%" }}
- className={clsx("excalidraw-app", {
- "is-collaborating": collabAPI?.isCollaborating(),
- })}
- >
- <Excalidraw
- ref={excalidrawRefCallback}
- onChange={onChange}
- initialData={initialStatePromiseRef.current.promise}
- onCollabButtonClick={collabAPI?.onCollabButtonClick}
- isCollaborating={collabAPI?.isCollaborating()}
- onPointerUpdate={collabAPI?.onPointerUpdate}
- UIOptions={{
- canvasActions: {
- export: {
- onExportToBackend,
- renderCustomUI: (elements, appState, files) => {
- return (
- <ExportToExcalidrawPlus
- elements={elements}
- appState={appState}
- files={files}
- onError={(error) => {
- excalidrawAPI?.updateScene({
- appState: {
- errorMessage: error.message,
- },
- });
- }}
- />
- );
- },
- },
- },
- }}
- renderTopRightUI={renderTopRightUI}
- renderFooter={renderFooter}
- langCode={langCode}
- renderCustomStats={renderCustomStats}
- detectScroll={false}
- handleKeyboardGlobally={true}
- onLibraryChange={onLibraryChange}
- autoFocus={true}
- />
- {excalidrawAPI && (
- <CollabWrapper
- excalidrawAPI={excalidrawAPI}
- onRoomClose={onRoomClose}
- />
- )}
- {errorMessage && (
- <ErrorDialog
- message={errorMessage}
- onClose={() => setErrorMessage("")}
- />
- )}
- </div>
- );
- };
- const ExcalidrawApp = () => {
- return (
- <TopErrorBoundary>
- <CollabContextConsumer>
- <ExcalidrawWrapper />
- </CollabContextConsumer>
- </TopErrorBoundary>
- );
- };
- export default ExcalidrawApp;
|