summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commitbfc2cec7d43eb8eaa46dd3f91084932381257059 (patch)
tree0857e3aac2cff922826d4871ff54536b26fad6fc
parent225db4a7805befe009fe055fc2ef5daedd6c04f9 (diff)
refactor: excalidraw-app/
-rw-r--r--excalidraw-app/App.tsx1154
-rw-r--r--excalidraw-app/CustomStats.tsx85
-rw-r--r--excalidraw-app/ExcalidrawPlusIframeExport.tsx222
-rw-r--r--excalidraw-app/app-jotai.ts37
-rw-r--r--excalidraw-app/app-language/LanguageList.tsx25
-rw-r--r--excalidraw-app/app-language/language-detector.ts25
-rw-r--r--excalidraw-app/app-language/language-state.ts15
-rw-r--r--excalidraw-app/app_constants.ts59
-rw-r--r--excalidraw-app/bug-issue-template.js11
-rw-r--r--excalidraw-app/collab/Collab.tsx1018
-rw-r--r--excalidraw-app/collab/CollabError.scss35
-rw-r--r--excalidraw-app/collab/CollabError.tsx54
-rw-r--r--excalidraw-app/collab/Portal.tsx256
-rw-r--r--excalidraw-app/components/AI.tsx159
-rw-r--r--excalidraw-app/components/AppFooter.tsx29
-rw-r--r--excalidraw-app/components/AppMainMenu.tsx86
-rw-r--r--excalidraw-app/components/AppWelcomeScreen.tsx72
-rw-r--r--excalidraw-app/components/DebugCanvas.tsx344
-rw-r--r--excalidraw-app/components/EncryptedIcon.tsx21
-rw-r--r--excalidraw-app/components/ExcalidrawPlusAppLink.tsx19
-rw-r--r--excalidraw-app/components/ExportToExcalidrawPlus.tsx133
-rw-r--r--excalidraw-app/components/GitHubCorner.tsx45
-rw-r--r--excalidraw-app/components/TopErrorBoundary.tsx146
-rw-r--r--excalidraw-app/data/FileManager.ts273
-rw-r--r--excalidraw-app/data/LocalData.ts258
-rw-r--r--excalidraw-app/data/Locker.ts18
-rw-r--r--excalidraw-app/data/firebase.ts313
-rw-r--r--excalidraw-app/data/index.ts338
-rw-r--r--excalidraw-app/data/localStorage.ts99
-rw-r--r--excalidraw-app/data/tabSync.ts39
-rw-r--r--excalidraw-app/debug.ts135
-rw-r--r--excalidraw-app/global.d.ts6
-rw-r--r--excalidraw-app/index.html252
-rw-r--r--excalidraw-app/index.scss117
-rw-r--r--excalidraw-app/index.tsx15
-rw-r--r--excalidraw-app/package.json56
-rw-r--r--excalidraw-app/sentry.ts81
-rw-r--r--excalidraw-app/share/ShareDialog.scss166
-rw-r--r--excalidraw-app/share/ShareDialog.tsx290
-rw-r--r--excalidraw-app/tests/LanguageList.test.tsx34
-rw-r--r--excalidraw-app/tests/MobileMenu.test.tsx51
-rw-r--r--excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap247
-rw-r--r--excalidraw-app/tests/collab.test.tsx247
-rw-r--r--excalidraw-app/useHandleAppTheme.ts69
-rw-r--r--excalidraw-app/vite-env.d.ts49
-rw-r--r--excalidraw-app/vite.config.mts269
46 files changed, 7472 insertions, 0 deletions
diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx
new file mode 100644
index 0000000..3d413f0
--- /dev/null
+++ b/excalidraw-app/App.tsx
@@ -0,0 +1,1154 @@
+import polyfill from "@excalidraw/excalidraw/polyfill";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { trackEvent } from "@excalidraw/excalidraw/analytics";
+import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
+import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
+import { TopErrorBoundary } from "./components/TopErrorBoundary";
+import {
+ APP_NAME,
+ EVENT,
+ THEME,
+ TITLE_TIMEOUT,
+ VERSION_TIMEOUT,
+} from "@excalidraw/excalidraw/constants";
+import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
+import type {
+ FileId,
+ NonDeletedExcalidrawElement,
+ OrderedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
+import { t } from "@excalidraw/excalidraw/i18n";
+import {
+ Excalidraw,
+ LiveCollaborationTrigger,
+ TTDDialogTrigger,
+ CaptureUpdateAction,
+ reconcileElements,
+} from "@excalidraw/excalidraw";
+import type {
+ AppState,
+ ExcalidrawImperativeAPI,
+ BinaryFiles,
+ ExcalidrawInitialDataState,
+ UIAppState,
+} from "@excalidraw/excalidraw/types";
+import type { ResolvablePromise } from "@excalidraw/excalidraw/utils";
+import {
+ debounce,
+ getVersion,
+ getFrame,
+ isTestEnv,
+ preventUnload,
+ resolvablePromise,
+ isRunningInIframe,
+} from "@excalidraw/excalidraw/utils";
+import {
+ FIREBASE_STORAGE_PREFIXES,
+ isExcalidrawPlusSignedUser,
+ STORAGE_KEYS,
+ SYNC_BROWSER_TABS_TIMEOUT,
+} from "./app_constants";
+import type { CollabAPI } from "./collab/Collab";
+import Collab, {
+ collabAPIAtom,
+ isCollaboratingAtom,
+ isOfflineAtom,
+} from "./collab/Collab";
+import {
+ exportToBackend,
+ getCollaborationLinkData,
+ isCollaborationLink,
+ loadScene,
+} from "./data";
+import {
+ importFromLocalStorage,
+ importUsernameFromLocalStorage,
+} from "./data/localStorage";
+import CustomStats from "./CustomStats";
+import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
+import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
+import {
+ ExportToExcalidrawPlus,
+ exportToExcalidrawPlus,
+} from "./components/ExportToExcalidrawPlus";
+import { updateStaleImageStatuses } from "./data/FileManager";
+import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
+import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
+import { loadFilesFromFirebase } from "./data/firebase";
+import {
+ LibraryIndexedDBAdapter,
+ LibraryLocalStorageMigrationAdapter,
+ LocalData,
+} from "./data/LocalData";
+import { isBrowserStorageStateNewer } from "./data/tabSync";
+import clsx from "clsx";
+import {
+ parseLibraryTokensFromUrl,
+ useHandleLibrary,
+} from "@excalidraw/excalidraw/data/library";
+import { AppMainMenu } from "./components/AppMainMenu";
+import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
+import { AppFooter } from "./components/AppFooter";
+import {
+ Provider,
+ useAtom,
+ useAtomValue,
+ useAtomWithInitialValue,
+ appJotaiStore,
+} from "./app-jotai";
+
+import "./index.scss";
+import type { ResolutionType } from "@excalidraw/excalidraw/utility-types";
+import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
+import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
+import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
+import Trans from "@excalidraw/excalidraw/components/Trans";
+import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
+import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
+import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
+import {
+ CommandPalette,
+ DEFAULT_CATEGORIES,
+} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
+import {
+ GithubIcon,
+ XBrandIcon,
+ DiscordIcon,
+ ExcalLogo,
+ usersIcon,
+ exportToPlus,
+ share,
+ youtubeIcon,
+} from "@excalidraw/excalidraw/components/icons";
+import { useHandleAppTheme } from "./useHandleAppTheme";
+import { getPreferredLanguage } from "./app-language/language-detector";
+import { useAppLangCode } from "./app-language/language-state";
+import DebugCanvas, {
+ debugRenderer,
+ isVisualDebuggerEnabled,
+ loadSavedDebugState,
+} from "./components/DebugCanvas";
+import { AIComponents } from "./components/AI";
+import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport";
+import { isElementLink } from "@excalidraw/excalidraw/element/elementLink";
+
+polyfill();
+
+window.EXCALIDRAW_THROTTLE_RENDER = true;
+
+declare global {
+ interface BeforeInstallPromptEventChoiceResult {
+ outcome: "accepted" | "dismissed";
+ }
+
+ interface BeforeInstallPromptEvent extends Event {
+ prompt(): Promise<void>;
+ userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
+ }
+
+ interface WindowEventMap {
+ beforeinstallprompt: BeforeInstallPromptEvent;
+ }
+}
+
+let pwaEvent: BeforeInstallPromptEvent | null = null;
+
+// Adding a listener outside of the component as it may (?) need to be
+// subscribed early to catch the event.
+//
+// Also note that it will fire only if certain heuristics are met (user has
+// used the app for some time, etc.)
+window.addEventListener(
+ "beforeinstallprompt",
+ (event: BeforeInstallPromptEvent) => {
+ // prevent Chrome <= 67 from automatically showing the prompt
+ event.preventDefault();
+ // cache for later use
+ pwaEvent = event;
+ },
+);
+
+let isSelfEmbedding = false;
+
+if (window.self !== window.top) {
+ try {
+ const parentUrl = new URL(document.referrer);
+ const currentUrl = new URL(window.location.href);
+ if (parentUrl.origin === currentUrl.origin) {
+ isSelfEmbedding = true;
+ }
+ } catch (error) {
+ // ignore
+ }
+}
+
+const shareableLinkConfirmDialog = {
+ title: t("overwriteConfirm.modal.shareableLink.title"),
+ description: (
+ <Trans
+ i18nKey="overwriteConfirm.modal.shareableLink.description"
+ bold={(text) => <strong>{text}</strong>}
+ br={() => <br />}
+ />
+ ),
+ actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
+ color: "danger",
+} as const;
+
+const initializeScene = async (opts: {
+ collabAPI: CollabAPI | null;
+ excalidrawAPI: ExcalidrawImperativeAPI;
+}): 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
+ (await openConfirmModal(shareableLinkConfirmDialog))
+ ) {
+ 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 ||
+ (await openConfirmModal(shareableLinkConfirmDialog))
+ ) {
+ return { scene: data, isExternalScene };
+ }
+ } catch (error: any) {
+ return {
+ scene: {
+ appState: {
+ errorMessage: t("alerts.invalidSceneUrl"),
+ },
+ },
+ isExternalScene,
+ };
+ }
+ }
+
+ if (roomLinkData && opts.collabAPI) {
+ const { excalidrawAPI } = opts;
+
+ const scene = await opts.collabAPI.startCollaboration(roomLinkData);
+
+ return {
+ // when collaborating, the state may have already been updated at this
+ // point (we may have received updates from other clients), so reconcile
+ // elements and appState with existing state
+ scene: {
+ ...scene,
+ appState: {
+ ...restoreAppState(
+ {
+ ...scene?.appState,
+ theme: localDataState?.appState?.theme || scene?.appState?.theme,
+ },
+ excalidrawAPI.getAppState(),
+ ),
+ // necessary if we're invoking from a hashchange handler which doesn't
+ // go through App.initializeScene() that resets this flag
+ isLoading: false,
+ },
+ elements: reconcileElements(
+ scene?.elements || [],
+ excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
+ excalidrawAPI.getAppState(),
+ ),
+ },
+ 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 ExcalidrawWrapper = () => {
+ const [errorMessage, setErrorMessage] = useState("");
+ const isCollabDisabled = isRunningInIframe();
+
+ const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme();
+
+ const [langCode, setLangCode] = useAppLangCode();
+
+ // initial state
+ // ---------------------------------------------------------------------------
+
+ const initialStatePromiseRef = useRef<{
+ promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
+ }>({ promise: null! });
+ if (!initialStatePromiseRef.current.promise) {
+ initialStatePromiseRef.current.promise =
+ resolvablePromise<ExcalidrawInitialDataState | null>();
+ }
+
+ const debugCanvasRef = useRef<HTMLCanvasElement>(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 [, setShareDialogState] = useAtom(shareDialogStateAtom);
+ const [collabAPI] = useAtom(collabAPIAtom);
+ const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
+ return isCollaborationLink(window.location.href);
+ });
+ const collabError = useAtomValue(collabErrorIndicatorAtom);
+
+ useHandleLibrary({
+ excalidrawAPI,
+ adapter: LibraryIndexedDBAdapter,
+ // TODO maybe remove this in several months (shipped: 24-03-11)
+ migrationAdapter: LibraryLocalStorageMigrationAdapter,
+ });
+
+ const [, forceRefresh] = useState(false);
+
+ useEffect(() => {
+ if (import.meta.env.DEV) {
+ const debugState = loadSavedDebugState();
+
+ if (debugState.enabled && !window.visualDebug) {
+ window.visualDebug = {
+ data: [],
+ };
+ } else {
+ delete window.visualDebug;
+ }
+ forceRefresh((prev) => !prev);
+ }
+ }, [excalidrawAPI]);
+
+ useEffect(() => {
+ if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
+ 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,
+ forceFetchFiles: true,
+ })
+ .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, excalidrawAPI }).then(async (data) => {
+ loadImages(data, /* isInitialLoad */ true);
+ initialStatePromiseRef.current.promise.resolve(data.scene);
+ });
+
+ const onHashChange = async (event: HashChangeEvent) => {
+ event.preventDefault();
+ const libraryUrlTokens = parseLibraryTokensFromUrl();
+ if (!libraryUrlTokens) {
+ if (
+ collabAPI?.isCollaborating() &&
+ !isCollaborationLink(window.location.href)
+ ) {
+ collabAPI.stopCollaboration(false);
+ }
+ excalidrawAPI.updateScene({ appState: { isLoading: true } });
+
+ initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
+ loadImages(data);
+ if (data.scene) {
+ excalidrawAPI.updateScene({
+ ...data.scene,
+ ...restore(data.scene, null, null, { repairBindings: true }),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+ }
+ });
+ }
+ };
+
+ const titleTimeout = setTimeout(
+ () => (document.title = APP_NAME),
+ TITLE_TIMEOUT,
+ );
+
+ const syncData = debounce(() => {
+ if (isTestEnv()) {
+ return;
+ }
+ if (
+ !document.hidden &&
+ ((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
+ ) {
+ // 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();
+ setLangCode(getPreferredLanguage());
+ excalidrawAPI.updateScene({
+ ...localDataState,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+ LibraryIndexedDBAdapter.load().then((data) => {
+ if (data) {
+ excalidrawAPI.updateLibrary({
+ libraryItems: data.libraryItems,
+ });
+ }
+ });
+ 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);
+ };
+ }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
+
+ 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]);
+
+ const onChange = (
+ elements: readonly OrderedExcalidrawElement[],
+ 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,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+ }
+ }
+ });
+ }
+
+ // Render the debug scene if the debug canvas is available
+ if (debugCanvasRef.current && excalidrawAPI) {
+ debugRenderer(
+ debugCanvasRef.current,
+ appState,
+ window.devicePixelRatio,
+ () => forceRefresh((prev) => !prev),
+ );
+ }
+ };
+
+ const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
+ null,
+ );
+
+ const onExportToBackend = async (
+ exportedElements: readonly NonDeletedExcalidrawElement[],
+ appState: Partial<AppState>,
+ files: BinaryFiles,
+ ) => {
+ if (exportedElements.length === 0) {
+ throw new Error(t("alerts.cannotExportEmptyCanvas"));
+ }
+ try {
+ const { url, errorMessage } = await exportToBackend(
+ exportedElements,
+ {
+ ...appState,
+ viewBackgroundColor: appState.exportBackground
+ ? appState.viewBackgroundColor
+ : getDefaultAppState().viewBackgroundColor,
+ },
+ files,
+ );
+
+ if (errorMessage) {
+ throw new Error(errorMessage);
+ }
+
+ if (url) {
+ setLatestShareableLink(url);
+ }
+ } catch (error: any) {
+ if (error.name !== "AbortError") {
+ const { width, height } = appState;
+ console.error(error, {
+ width,
+ height,
+ devicePixelRatio: window.devicePixelRatio,
+ });
+ throw new Error(error.message);
+ }
+ }
+ };
+
+ const renderCustomStats = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: UIAppState,
+ ) => {
+ return (
+ <CustomStats
+ setToast={(message) => excalidrawAPI!.setToast({ message })}
+ appState={appState}
+ elements={elements}
+ />
+ );
+ };
+
+ const isOffline = useAtomValue(isOfflineAtom);
+
+ const onCollabDialogOpen = useCallback(
+ () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
+ [setShareDialogState],
+ );
+
+ // browsers generally prevent infinite self-embedding, there are
+ // cases where it still happens, and while we disallow self-embedding
+ // by not whitelisting our own origin, this serves as an additional guard
+ if (isSelfEmbedding) {
+ return (
+ <div
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ textAlign: "center",
+ height: "100%",
+ }}
+ >
+ <h1>I'm not a pretzel!</h1>
+ </div>
+ );
+ }
+
+ const ExcalidrawPlusCommand = {
+ label: "kj-diagramming cloud",
+ category: DEFAULT_CATEGORIES.links,
+ predicate: true,
+ icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
+ keywords: ["plus", "cloud", "server"],
+ perform: () => {
+ window.open(
+ `${
+ import.meta.env.VITE_APP_PLUS_LP
+ }/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
+ "_blank",
+ );
+ },
+ };
+ const ExcalidrawPlusAppCommand = {
+ label: "Sign up",
+ category: DEFAULT_CATEGORIES.links,
+ predicate: true,
+ icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
+ keywords: [
+ "diagramming",
+ "plus",
+ "cloud",
+ "server",
+ "signin",
+ "login",
+ "signup",
+ ],
+ perform: () => {
+ window.open(
+ `${
+ import.meta.env.VITE_APP_PLUS_APP
+ }?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
+ "_blank",
+ );
+ },
+ };
+
+ return (
+ <div
+ style={{ height: "100%" }}
+ className={clsx("excalidraw-app", {
+ "is-collaborating": isCollaborating,
+ })}
+ >
+ <Excalidraw
+ excalidrawAPI={excalidrawRefCallback}
+ onChange={onChange}
+ initialData={initialStatePromiseRef.current.promise}
+ isCollaborating={isCollaborating}
+ onPointerUpdate={collabAPI?.onPointerUpdate}
+ UIOptions={{
+ canvasActions: {
+ toggleTheme: true,
+ export: {
+ onExportToBackend,
+ renderCustomUI: excalidrawAPI
+ ? (elements, appState, files) => {
+ return (
+ <ExportToExcalidrawPlus
+ elements={elements}
+ appState={appState}
+ files={files}
+ name={excalidrawAPI.getName()}
+ onError={(error) => {
+ excalidrawAPI?.updateScene({
+ appState: {
+ errorMessage: error.message,
+ },
+ });
+ }}
+ onSuccess={() => {
+ excalidrawAPI.updateScene({
+ appState: { openDialog: null },
+ });
+ }}
+ />
+ );
+ }
+ : undefined,
+ },
+ },
+ }}
+ langCode={langCode}
+ renderCustomStats={renderCustomStats}
+ detectScroll={false}
+ handleKeyboardGlobally={true}
+ autoFocus={true}
+ theme={editorTheme}
+ renderTopRightUI={(isMobile) => {
+ if (isMobile || !collabAPI || isCollabDisabled) {
+ return null;
+ }
+ return (
+ <div className="top-right-ui">
+ {collabError.message && <CollabError collabError={collabError} />}
+ <LiveCollaborationTrigger
+ isCollaborating={isCollaborating}
+ onSelect={() =>
+ setShareDialogState({ isOpen: true, type: "share" })
+ }
+ />
+ </div>
+ );
+ }}
+ onLinkOpen={(element, event) => {
+ if (element.link && isElementLink(element.link)) {
+ event.preventDefault();
+ excalidrawAPI?.scrollToContent(element.link, { animate: true });
+ }
+ }}
+ >
+ <AppMainMenu
+ onCollabDialogOpen={onCollabDialogOpen}
+ isCollaborating={isCollaborating}
+ isCollabEnabled={!isCollabDisabled}
+ theme={appTheme}
+ setTheme={(theme) => setAppTheme(theme)}
+ refresh={() => forceRefresh((prev) => !prev)}
+ />
+ <AppWelcomeScreen
+ onCollabDialogOpen={onCollabDialogOpen}
+ isCollabEnabled={!isCollabDisabled}
+ />
+ <OverwriteConfirmDialog>
+ <OverwriteConfirmDialog.Actions.ExportToImage />
+ <OverwriteConfirmDialog.Actions.SaveToDisk />
+ {excalidrawAPI && (
+ <OverwriteConfirmDialog.Action
+ title={t("overwriteConfirm.action.excalidrawPlus.title")}
+ actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
+ onClick={() => {
+ exportToExcalidrawPlus(
+ excalidrawAPI.getSceneElements(),
+ excalidrawAPI.getAppState(),
+ excalidrawAPI.getFiles(),
+ excalidrawAPI.getName(),
+ );
+ }}
+ >
+ {t("overwriteConfirm.action.excalidrawPlus.description")}
+ </OverwriteConfirmDialog.Action>
+ )}
+ </OverwriteConfirmDialog>
+ <AppFooter onChange={() => excalidrawAPI?.refresh()} />
+ {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
+
+ <TTDDialogTrigger />
+ {isCollaborating && isOffline && (
+ <div className="collab-offline-warning">
+ {t("alerts.collabOfflineWarning")}
+ </div>
+ )}
+ {latestShareableLink && (
+ <ShareableLinkDialog
+ link={latestShareableLink}
+ onCloseRequest={() => setLatestShareableLink(null)}
+ setErrorMessage={setErrorMessage}
+ />
+ )}
+ {excalidrawAPI && !isCollabDisabled && (
+ <Collab excalidrawAPI={excalidrawAPI} />
+ )}
+
+ <ShareDialog
+ collabAPI={collabAPI}
+ onExportToBackend={async () => {
+ if (excalidrawAPI) {
+ try {
+ await onExportToBackend(
+ excalidrawAPI.getSceneElements(),
+ excalidrawAPI.getAppState(),
+ excalidrawAPI.getFiles(),
+ );
+ } catch (error: any) {
+ setErrorMessage(error.message);
+ }
+ }
+ }}
+ />
+
+ {errorMessage && (
+ <ErrorDialog onClose={() => setErrorMessage("")}>
+ {errorMessage}
+ </ErrorDialog>
+ )}
+
+ <CommandPalette
+ customCommandPaletteItems={[
+ {
+ label: t("labels.liveCollaboration"),
+ category: DEFAULT_CATEGORIES.app,
+ keywords: [
+ "team",
+ "multiplayer",
+ "share",
+ "public",
+ "session",
+ "invite",
+ ],
+ icon: usersIcon,
+ perform: () => {
+ setShareDialogState({
+ isOpen: true,
+ type: "collaborationOnly",
+ });
+ },
+ },
+ {
+ label: t("roomDialog.button_stopSession"),
+ category: DEFAULT_CATEGORIES.app,
+ predicate: () => !!collabAPI?.isCollaborating(),
+ keywords: [
+ "stop",
+ "session",
+ "end",
+ "leave",
+ "close",
+ "exit",
+ "collaboration",
+ ],
+ perform: () => {
+ if (collabAPI) {
+ collabAPI.stopCollaboration();
+ if (!collabAPI.isCollaborating()) {
+ setShareDialogState({ isOpen: false });
+ }
+ }
+ },
+ },
+ {
+ label: t("labels.share"),
+ category: DEFAULT_CATEGORIES.app,
+ predicate: true,
+ icon: share,
+ keywords: [
+ "link",
+ "shareable",
+ "readonly",
+ "export",
+ "publish",
+ "snapshot",
+ "url",
+ "collaborate",
+ "invite",
+ ],
+ perform: async () => {
+ setShareDialogState({ isOpen: true, type: "share" });
+ },
+ },
+ {
+ label: "GitHub",
+ icon: GithubIcon,
+ category: DEFAULT_CATEGORIES.links,
+ predicate: true,
+ keywords: [
+ "issues",
+ "bugs",
+ "requests",
+ "report",
+ "features",
+ "social",
+ "community",
+ ],
+ perform: () => {
+ window.open(
+ "https://github.com/excalidraw/excalidraw",
+ "_blank",
+ "noopener noreferrer",
+ );
+ },
+ },
+ {
+ label: t("labels.followUs"),
+ icon: XBrandIcon,
+ category: DEFAULT_CATEGORIES.links,
+ predicate: true,
+ keywords: ["twitter", "contact", "social", "community"],
+ perform: () => {
+ window.open(
+ "https://x.com/excalidraw",
+ "_blank",
+ "noopener noreferrer",
+ );
+ },
+ },
+ {
+ label: t("labels.discordChat"),
+ category: DEFAULT_CATEGORIES.links,
+ predicate: true,
+ icon: DiscordIcon,
+ keywords: [
+ "chat",
+ "talk",
+ "contact",
+ "bugs",
+ "requests",
+ "report",
+ "feedback",
+ "suggestions",
+ "social",
+ "community",
+ ],
+ perform: () => {
+ window.open(
+ "https://discord.gg/UexuTaE",
+ "_blank",
+ "noopener noreferrer",
+ );
+ },
+ },
+ {
+ label: "YouTube",
+ icon: youtubeIcon,
+ category: DEFAULT_CATEGORIES.links,
+ predicate: true,
+ keywords: ["features", "tutorials", "howto", "help", "community"],
+ perform: () => {
+ window.open(
+ "https://youtube.com/@excalidraw",
+ "_blank",
+ "noopener noreferrer",
+ );
+ },
+ },
+ ...(isExcalidrawPlusSignedUser
+ ? [
+ {
+ ...ExcalidrawPlusAppCommand,
+ label: "Sign in / Go to kj-diagramming cloud",
+ },
+ ]
+ : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
+
+ {
+ label: t("overwriteConfirm.action.excalidrawPlus.button"),
+ category: DEFAULT_CATEGORIES.export,
+ icon: exportToPlus,
+ predicate: true,
+ keywords: ["plus", "export", "save", "backup"],
+ perform: () => {
+ if (excalidrawAPI) {
+ exportToExcalidrawPlus(
+ excalidrawAPI.getSceneElements(),
+ excalidrawAPI.getAppState(),
+ excalidrawAPI.getFiles(),
+ excalidrawAPI.getName(),
+ );
+ }
+ },
+ },
+ {
+ ...CommandPalette.defaultItems.toggleTheme,
+ perform: () => {
+ setAppTheme(
+ editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
+ );
+ },
+ },
+ {
+ label: t("labels.installPWA"),
+ category: DEFAULT_CATEGORIES.app,
+ predicate: () => !!pwaEvent,
+ perform: () => {
+ if (pwaEvent) {
+ pwaEvent.prompt();
+ pwaEvent.userChoice.then(() => {
+ // event cannot be reused, but we'll hopefully
+ // grab new one as the event should be fired again
+ pwaEvent = null;
+ });
+ }
+ },
+ },
+ ]}
+ />
+ {isVisualDebuggerEnabled() && excalidrawAPI && (
+ <DebugCanvas
+ appState={excalidrawAPI.getAppState()}
+ scale={window.devicePixelRatio}
+ ref={debugCanvasRef}
+ />
+ )}
+ </Excalidraw>
+ </div>
+ );
+};
+
+const ExcalidrawApp = () => {
+ const isCloudExportWindow =
+ window.location.pathname === "/excalidraw-plus-export";
+ if (isCloudExportWindow) {
+ return <ExcalidrawPlusIframeExport />;
+ }
+
+ return (
+ <TopErrorBoundary>
+ <Provider store={appJotaiStore}>
+ <ExcalidrawWrapper />
+ </Provider>
+ </TopErrorBoundary>
+ );
+};
+
+export default ExcalidrawApp;
diff --git a/excalidraw-app/CustomStats.tsx b/excalidraw-app/CustomStats.tsx
new file mode 100644
index 0000000..96ca55d
--- /dev/null
+++ b/excalidraw-app/CustomStats.tsx
@@ -0,0 +1,85 @@
+import { useEffect, useState } from "react";
+import { debounce, getVersion, nFormatter } from "@excalidraw/excalidraw/utils";
+import {
+ getElementsStorageSize,
+ getTotalStorageSize,
+} from "./data/localStorage";
+import { DEFAULT_VERSION } from "@excalidraw/excalidraw/constants";
+import { t } from "@excalidraw/excalidraw/i18n";
+import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
+import type { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
+import type { UIAppState } from "@excalidraw/excalidraw/types";
+import { Stats } from "@excalidraw/excalidraw";
+
+type StorageSizes = { scene: number; total: number };
+
+const STORAGE_SIZE_TIMEOUT = 500;
+
+const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => {
+ cb({
+ scene: getElementsStorageSize(),
+ total: getTotalStorageSize(),
+ });
+}, STORAGE_SIZE_TIMEOUT);
+
+type Props = {
+ setToast: (message: string) => void;
+ elements: readonly NonDeletedExcalidrawElement[];
+ appState: UIAppState;
+};
+const CustomStats = (props: Props) => {
+ const [storageSizes, setStorageSizes] = useState<StorageSizes>({
+ scene: 0,
+ total: 0,
+ });
+
+ useEffect(() => {
+ getStorageSizes((sizes) => {
+ setStorageSizes(sizes);
+ });
+ }, [props.elements, props.appState]);
+ useEffect(() => () => getStorageSizes.cancel(), []);
+
+ const version = getVersion();
+ let hash;
+ let timestamp;
+
+ if (version !== DEFAULT_VERSION) {
+ timestamp = version.slice(0, 16).replace("T", " ");
+ hash = version.slice(21);
+ } else {
+ timestamp = t("stats.versionNotAvailable");
+ }
+
+ return (
+ <Stats.StatsRows order={-1}>
+ <Stats.StatsRow heading>{t("stats.version")}</Stats.StatsRow>
+ <Stats.StatsRow
+ style={{ textAlign: "center", cursor: "pointer" }}
+ onClick={async () => {
+ try {
+ await copyTextToSystemClipboard(getVersion());
+ props.setToast(t("toast.copyToClipboard"));
+ } catch {}
+ }}
+ title={t("stats.versionCopy")}
+ >
+ {timestamp}
+ <br />
+ {hash}
+ </Stats.StatsRow>
+
+ <Stats.StatsRow heading>{t("stats.storage")}</Stats.StatsRow>
+ <Stats.StatsRow columns={2}>
+ <div>{t("stats.scene")}</div>
+ <div>{nFormatter(storageSizes.scene, 1)}</div>
+ </Stats.StatsRow>
+ <Stats.StatsRow columns={2}>
+ <div>{t("stats.total")}</div>
+ <div>{nFormatter(storageSizes.total, 1)}</div>
+ </Stats.StatsRow>
+ </Stats.StatsRows>
+ );
+};
+
+export default CustomStats;
diff --git a/excalidraw-app/ExcalidrawPlusIframeExport.tsx b/excalidraw-app/ExcalidrawPlusIframeExport.tsx
new file mode 100644
index 0000000..1f9cd63
--- /dev/null
+++ b/excalidraw-app/ExcalidrawPlusIframeExport.tsx
@@ -0,0 +1,222 @@
+import { useLayoutEffect, useRef } from "react";
+import { STORAGE_KEYS } from "./app_constants";
+import { LocalData } from "./data/LocalData";
+import type {
+ FileId,
+ OrderedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import type { AppState, BinaryFileData } from "@excalidraw/excalidraw/types";
+import { ExcalidrawError } from "@excalidraw/excalidraw/errors";
+import { base64urlToString } from "@excalidraw/excalidraw/data/encode";
+
+const EVENT_REQUEST_SCENE = "REQUEST_SCENE";
+
+const EXCALIDRAW_PLUS_ORIGIN = import.meta.env.VITE_APP_PLUS_APP;
+
+// -----------------------------------------------------------------------------
+// outgoing message
+// -----------------------------------------------------------------------------
+type MESSAGE_REQUEST_SCENE = {
+ type: "REQUEST_SCENE";
+ jwt: string;
+};
+
+type MESSAGE_FROM_PLUS = MESSAGE_REQUEST_SCENE;
+
+// incoming messages
+// -----------------------------------------------------------------------------
+type MESSAGE_READY = { type: "READY" };
+type MESSAGE_ERROR = { type: "ERROR"; message: string };
+type MESSAGE_SCENE_DATA = {
+ type: "SCENE_DATA";
+ elements: OrderedExcalidrawElement[];
+ appState: Pick<AppState, "viewBackgroundColor">;
+ files: { loadedFiles: BinaryFileData[]; erroredFiles: Map<FileId, true> };
+};
+
+type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY;
+// -----------------------------------------------------------------------------
+
+const parseSceneData = async ({
+ rawElementsString,
+ rawAppStateString,
+}: {
+ rawElementsString: string | null;
+ rawAppStateString: string | null;
+}): Promise<MESSAGE_SCENE_DATA> => {
+ if (!rawElementsString || !rawAppStateString) {
+ throw new ExcalidrawError("Elements or appstate is missing.");
+ }
+
+ try {
+ const elements = JSON.parse(
+ rawElementsString,
+ ) as OrderedExcalidrawElement[];
+
+ if (!elements.length) {
+ throw new ExcalidrawError("Scene is empty, nothing to export.");
+ }
+
+ const appState = JSON.parse(rawAppStateString) as Pick<
+ AppState,
+ "viewBackgroundColor"
+ >;
+
+ const fileIds = elements.reduce((acc, el) => {
+ if ("fileId" in el && el.fileId) {
+ acc.push(el.fileId);
+ }
+ return acc;
+ }, [] as FileId[]);
+
+ const files = await LocalData.fileStorage.getFiles(fileIds);
+
+ return {
+ type: "SCENE_DATA",
+ elements,
+ appState,
+ files,
+ };
+ } catch (error: any) {
+ throw error instanceof ExcalidrawError
+ ? error
+ : new ExcalidrawError("Failed to parse scene data.");
+ }
+};
+
+const verifyJWT = async ({
+ token,
+ publicKey,
+}: {
+ token: string;
+ publicKey: string;
+}) => {
+ try {
+ if (!publicKey) {
+ throw new ExcalidrawError("Public key is undefined");
+ }
+
+ const [header, payload, signature] = token.split(".");
+
+ if (!header || !payload || !signature) {
+ throw new ExcalidrawError("Invalid JWT format");
+ }
+
+ // JWT is using Base64URL encoding
+ const decodedPayload = base64urlToString(payload);
+ const decodedSignature = base64urlToString(signature);
+
+ const data = `${header}.${payload}`;
+ const signatureArrayBuffer = Uint8Array.from(decodedSignature, (c) =>
+ c.charCodeAt(0),
+ );
+
+ const keyData = publicKey.replace(/-----\w+ PUBLIC KEY-----/g, "");
+ const keyArrayBuffer = Uint8Array.from(atob(keyData), (c) =>
+ c.charCodeAt(0),
+ );
+
+ const key = await crypto.subtle.importKey(
+ "spki",
+ keyArrayBuffer,
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
+ true,
+ ["verify"],
+ );
+
+ const isValid = await crypto.subtle.verify(
+ "RSASSA-PKCS1-v1_5",
+ key,
+ signatureArrayBuffer,
+ new TextEncoder().encode(data),
+ );
+
+ if (!isValid) {
+ throw new Error("Invalid JWT");
+ }
+
+ const parsedPayload = JSON.parse(decodedPayload);
+
+ // Check for expiration
+ const currentTime = Math.floor(Date.now() / 1000);
+ if (parsedPayload.exp && parsedPayload.exp < currentTime) {
+ throw new Error("JWT has expired");
+ }
+ } catch (error) {
+ console.error("Failed to verify JWT:", error);
+ throw new Error(error instanceof Error ? error.message : "Invalid JWT");
+ }
+};
+
+export const ExcalidrawPlusIframeExport = () => {
+ const readyRef = useRef(false);
+
+ useLayoutEffect(() => {
+ const handleMessage = async (event: MessageEvent<MESSAGE_FROM_PLUS>) => {
+ if (event.origin !== EXCALIDRAW_PLUS_ORIGIN) {
+ throw new ExcalidrawError("Invalid origin");
+ }
+
+ if (event.data.type === EVENT_REQUEST_SCENE) {
+ if (!event.data.jwt) {
+ throw new ExcalidrawError("JWT is missing");
+ }
+
+ try {
+ try {
+ await verifyJWT({
+ token: event.data.jwt,
+ publicKey: import.meta.env.VITE_APP_PLUS_EXPORT_PUBLIC_KEY,
+ });
+ } catch (error: any) {
+ console.error(`Failed to verify JWT: ${error.message}`);
+ throw new ExcalidrawError("Failed to verify JWT");
+ }
+
+ const parsedSceneData: MESSAGE_SCENE_DATA = await parseSceneData({
+ rawAppStateString: localStorage.getItem(
+ STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+ ),
+ rawElementsString: localStorage.getItem(
+ STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
+ ),
+ });
+
+ event.source!.postMessage(parsedSceneData, {
+ targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
+ });
+ } catch (error) {
+ const responseData: MESSAGE_ERROR = {
+ type: "ERROR",
+ message:
+ error instanceof ExcalidrawError
+ ? error.message
+ : "Failed to export scene data",
+ };
+ event.source!.postMessage(responseData, {
+ targetOrigin: EXCALIDRAW_PLUS_ORIGIN,
+ });
+ }
+ }
+ };
+
+ window.addEventListener("message", handleMessage);
+
+ // so we don't send twice in StrictMode
+ if (!readyRef.current) {
+ readyRef.current = true;
+ const message: MESSAGE_FROM_EDITOR = { type: "READY" };
+ window.parent.postMessage(message, EXCALIDRAW_PLUS_ORIGIN);
+ }
+
+ return () => {
+ window.removeEventListener("message", handleMessage);
+ };
+ }, []);
+
+ // Since this component is expected to run in a hidden iframe on Excaildraw+,
+ // it doesn't need to render anything. All the data we need is available in
+ // LocalStorage and IndexedDB. It only needs to handle the messaging between
+ // the parent window and the iframe with the relevant data.
+ return null;
+};
diff --git a/excalidraw-app/app-jotai.ts b/excalidraw-app/app-jotai.ts
new file mode 100644
index 0000000..1591803
--- /dev/null
+++ b/excalidraw-app/app-jotai.ts
@@ -0,0 +1,37 @@
+// eslint-disable-next-line no-restricted-imports
+import {
+ atom,
+ Provider,
+ useAtom,
+ useAtomValue,
+ useSetAtom,
+ createStore,
+ type PrimitiveAtom,
+} from "jotai";
+import { useLayoutEffect } from "react";
+
+export const appJotaiStore = createStore();
+
+export { atom, Provider, useAtom, useAtomValue, useSetAtom };
+
+export const useAtomWithInitialValue = <
+ T extends unknown,
+ A extends PrimitiveAtom<T>,
+>(
+ atom: A,
+ initialValue: T | (() => T),
+) => {
+ const [value, setValue] = useAtom(atom);
+
+ useLayoutEffect(() => {
+ if (typeof initialValue === "function") {
+ // @ts-ignore
+ setValue(initialValue());
+ } else {
+ setValue(initialValue);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return [value, setValue] as const;
+};
diff --git a/excalidraw-app/app-language/LanguageList.tsx b/excalidraw-app/app-language/LanguageList.tsx
new file mode 100644
index 0000000..001d4ea
--- /dev/null
+++ b/excalidraw-app/app-language/LanguageList.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import { useI18n, languages } from "@excalidraw/excalidraw/i18n";
+import { useSetAtom } from "../app-jotai";
+import { appLangCodeAtom } from "./language-state";
+
+export const LanguageList = ({ style }: { style?: React.CSSProperties }) => {
+ const { t, langCode } = useI18n();
+ const setLangCode = useSetAtom(appLangCodeAtom);
+
+ return (
+ <select
+ className="dropdown-select dropdown-select__language"
+ onChange={({ target }) => setLangCode(target.value)}
+ value={langCode}
+ aria-label={t("buttons.selectLanguage")}
+ style={style}
+ >
+ {languages.map((lang) => (
+ <option key={lang.code} value={lang.code}>
+ {lang.label}
+ </option>
+ ))}
+ </select>
+ );
+};
diff --git a/excalidraw-app/app-language/language-detector.ts b/excalidraw-app/app-language/language-detector.ts
new file mode 100644
index 0000000..52da490
--- /dev/null
+++ b/excalidraw-app/app-language/language-detector.ts
@@ -0,0 +1,25 @@
+import LanguageDetector from "i18next-browser-languagedetector";
+import { defaultLang, languages } from "@excalidraw/excalidraw";
+
+export const languageDetector = new LanguageDetector();
+
+languageDetector.init({
+ languageUtils: {},
+});
+
+export const getPreferredLanguage = () => {
+ const detectedLanguages = languageDetector.detect();
+
+ const detectedLanguage = Array.isArray(detectedLanguages)
+ ? detectedLanguages[0]
+ : detectedLanguages;
+
+ const initialLanguage =
+ (detectedLanguage
+ ? // region code may not be defined if user uses generic preferred language
+ // (e.g. chinese vs instead of chinese-simplified)
+ languages.find((lang) => lang.code.startsWith(detectedLanguage))?.code
+ : null) || defaultLang.code;
+
+ return initialLanguage;
+};
diff --git a/excalidraw-app/app-language/language-state.ts b/excalidraw-app/app-language/language-state.ts
new file mode 100644
index 0000000..f491c22
--- /dev/null
+++ b/excalidraw-app/app-language/language-state.ts
@@ -0,0 +1,15 @@
+import { useEffect } from "react";
+import { atom, useAtom } from "../app-jotai";
+import { getPreferredLanguage, languageDetector } from "./language-detector";
+
+export const appLangCodeAtom = atom(getPreferredLanguage());
+
+export const useAppLangCode = () => {
+ const [langCode, setLangCode] = useAtom(appLangCodeAtom);
+
+ useEffect(() => {
+ languageDetector.cacheUserLanguage(langCode);
+ }, [langCode]);
+
+ return [langCode, setLangCode] as const;
+};
diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts
new file mode 100644
index 0000000..461b4b7
--- /dev/null
+++ b/excalidraw-app/app_constants.ts
@@ -0,0 +1,59 @@
+// time constants (ms)
+export const SAVE_TO_LOCAL_STORAGE_TIMEOUT = 300;
+export const INITIAL_SCENE_UPDATE_TIMEOUT = 5000;
+export const FILE_UPLOAD_TIMEOUT = 300;
+export const LOAD_IMAGES_TIMEOUT = 500;
+export const SYNC_FULL_SCENE_INTERVAL_MS = 20000;
+export const SYNC_BROWSER_TABS_TIMEOUT = 50;
+export const CURSOR_SYNC_TIMEOUT = 33; // ~30fps
+export const DELETED_ELEMENT_TIMEOUT = 24 * 60 * 60 * 1000; // 1 day
+
+export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
+// 1 year (https://stackoverflow.com/a/25201898/927631)
+export const FILE_CACHE_MAX_AGE_SEC = 31536000;
+
+export const WS_EVENTS = {
+ SERVER_VOLATILE: "server-volatile-broadcast",
+ SERVER: "server-broadcast",
+ USER_FOLLOW_CHANGE: "user-follow",
+ USER_FOLLOW_ROOM_CHANGE: "user-follow-room-change",
+} as const;
+
+export enum WS_SUBTYPES {
+ INVALID_RESPONSE = "INVALID_RESPONSE",
+ INIT = "SCENE_INIT",
+ UPDATE = "SCENE_UPDATE",
+ MOUSE_LOCATION = "MOUSE_LOCATION",
+ IDLE_STATUS = "IDLE_STATUS",
+ USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",
+}
+
+export const FIREBASE_STORAGE_PREFIXES = {
+ shareLinkFiles: `/files/shareLinks`,
+ collabFiles: `/files/rooms`,
+};
+
+export const ROOM_ID_BYTES = 10;
+
+export const STORAGE_KEYS = {
+ LOCAL_STORAGE_ELEMENTS: "excalidraw",
+ LOCAL_STORAGE_APP_STATE: "excalidraw-state",
+ LOCAL_STORAGE_COLLAB: "excalidraw-collab",
+ LOCAL_STORAGE_THEME: "kj-diagramming-theme",
+ LOCAL_STORAGE_DEBUG: "excalidraw-debug",
+ VERSION_DATA_STATE: "version-dataState",
+ VERSION_FILES: "version-files",
+
+ IDB_LIBRARY: "excalidraw-library",
+
+ // do not use apart from migrations
+ __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library",
+} as const;
+
+export const COOKIES = {
+ AUTH_STATE_COOKIE: "excplus-auth",
+} as const;
+
+export const isExcalidrawPlusSignedUser = document.cookie.includes(
+ COOKIES.AUTH_STATE_COOKIE,
+);
diff --git a/excalidraw-app/bug-issue-template.js b/excalidraw-app/bug-issue-template.js
new file mode 100644
index 0000000..bc04886
--- /dev/null
+++ b/excalidraw-app/bug-issue-template.js
@@ -0,0 +1,11 @@
+export default (sentryErrorId) => `
+### Scene content
+
+\`\`\`
+Paste scene content here
+\`\`\`
+
+### Sentry Error ID
+
+${sentryErrorId}
+`;
diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx
new file mode 100644
index 0000000..9a16e05
--- /dev/null
+++ b/excalidraw-app/collab/Collab.tsx
@@ -0,0 +1,1018 @@
+import throttle from "lodash.throttle";
+import { PureComponent } from "react";
+import type {
+ BinaryFileData,
+ ExcalidrawImperativeAPI,
+ SocketId,
+ Collaborator,
+ Gesture,
+} from "@excalidraw/excalidraw/types";
+import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
+import { APP_NAME, ENV, EVENT } from "@excalidraw/excalidraw/constants";
+import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
+import type {
+ ExcalidrawElement,
+ FileId,
+ InitializedExcalidrawImageElement,
+ OrderedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import {
+ CaptureUpdateAction,
+ getSceneVersion,
+ restoreElements,
+ zoomToFitBounds,
+ reconcileElements,
+} from "@excalidraw/excalidraw";
+import {
+ assertNever,
+ preventUnload,
+ resolvablePromise,
+ throttleRAF,
+} from "@excalidraw/excalidraw/utils";
+import {
+ CURSOR_SYNC_TIMEOUT,
+ FILE_UPLOAD_MAX_BYTES,
+ FIREBASE_STORAGE_PREFIXES,
+ INITIAL_SCENE_UPDATE_TIMEOUT,
+ LOAD_IMAGES_TIMEOUT,
+ WS_SUBTYPES,
+ SYNC_FULL_SCENE_INTERVAL_MS,
+ WS_EVENTS,
+} from "../app_constants";
+import type {
+ SocketUpdateDataSource,
+ SyncableExcalidrawElement,
+} from "../data";
+import {
+ generateCollaborationLinkData,
+ getCollaborationLink,
+ getSyncableElements,
+} from "../data";
+import {
+ isSavedToFirebase,
+ loadFilesFromFirebase,
+ loadFromFirebase,
+ saveFilesToFirebase,
+ saveToFirebase,
+} from "../data/firebase";
+import {
+ importUsernameFromLocalStorage,
+ saveUsernameToLocalStorage,
+} from "../data/localStorage";
+import Portal from "./Portal";
+import { t } from "@excalidraw/excalidraw/i18n";
+import {
+ IDLE_THRESHOLD,
+ ACTIVE_THRESHOLD,
+ UserIdleState,
+} from "@excalidraw/excalidraw/constants";
+import {
+ encodeFilesForUpload,
+ FileManager,
+ updateStaleImageStatuses,
+} from "../data/FileManager";
+import { AbortError } from "@excalidraw/excalidraw/errors";
+import {
+ isImageElement,
+ isInitializedImageElement,
+} from "@excalidraw/excalidraw/element/typeChecks";
+import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
+import { decryptData } from "@excalidraw/excalidraw/data/encryption";
+import { resetBrowserStateVersions } from "../data/tabSync";
+import { LocalData } from "../data/LocalData";
+import { appJotaiStore, atom } from "../app-jotai";
+import type { Mutable, ValueOf } from "@excalidraw/excalidraw/utility-types";
+import { getVisibleSceneBounds } from "@excalidraw/excalidraw/element/bounds";
+import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
+import { collabErrorIndicatorAtom } from "./CollabError";
+import type {
+ ReconciledExcalidrawElement,
+ RemoteExcalidrawElement,
+} from "@excalidraw/excalidraw/data/reconcile";
+
+export const collabAPIAtom = atom<CollabAPI | null>(null);
+export const isCollaboratingAtom = atom(false);
+export const isOfflineAtom = atom(false);
+
+interface CollabState {
+ errorMessage: string | null;
+ /** errors related to saving */
+ dialogNotifiedErrors: Record<string, boolean>;
+ username: string;
+ activeRoomLink: string | null;
+}
+
+export const activeRoomLinkAtom = atom<string | null>(null);
+
+type CollabInstance = InstanceType<typeof Collab>;
+
+export interface CollabAPI {
+ /** function so that we can access the latest value from stale callbacks */
+ isCollaborating: () => boolean;
+ onPointerUpdate: CollabInstance["onPointerUpdate"];
+ startCollaboration: CollabInstance["startCollaboration"];
+ stopCollaboration: CollabInstance["stopCollaboration"];
+ syncElements: CollabInstance["syncElements"];
+ fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
+ setUsername: CollabInstance["setUsername"];
+ getUsername: CollabInstance["getUsername"];
+ getActiveRoomLink: CollabInstance["getActiveRoomLink"];
+ setCollabError: CollabInstance["setErrorDialog"];
+}
+
+interface CollabProps {
+ excalidrawAPI: ExcalidrawImperativeAPI;
+}
+
+class Collab extends PureComponent<CollabProps, CollabState> {
+ portal: Portal;
+ fileManager: FileManager;
+ excalidrawAPI: CollabProps["excalidrawAPI"];
+ activeIntervalId: number | null;
+ idleTimeoutId: number | null;
+
+ private socketInitializationTimer?: number;
+ private lastBroadcastedOrReceivedSceneVersion: number = -1;
+ private collaborators = new Map<SocketId, Collaborator>();
+
+ constructor(props: CollabProps) {
+ super(props);
+ this.state = {
+ errorMessage: null,
+ dialogNotifiedErrors: {},
+ username: importUsernameFromLocalStorage() || "",
+ activeRoomLink: null,
+ };
+ this.portal = new Portal(this);
+ this.fileManager = new FileManager({
+ getFiles: async (fileIds) => {
+ const { roomId, roomKey } = this.portal;
+ if (!roomId || !roomKey) {
+ throw new AbortError();
+ }
+
+ return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
+ },
+ saveFiles: async ({ addedFiles }) => {
+ const { roomId, roomKey } = this.portal;
+ if (!roomId || !roomKey) {
+ throw new AbortError();
+ }
+
+ const { savedFiles, erroredFiles } = await saveFilesToFirebase({
+ prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
+ files: await encodeFilesForUpload({
+ files: addedFiles,
+ encryptionKey: roomKey,
+ maxBytes: FILE_UPLOAD_MAX_BYTES,
+ }),
+ });
+
+ return {
+ savedFiles: savedFiles.reduce(
+ (acc: Map<FileId, BinaryFileData>, id) => {
+ const fileData = addedFiles.get(id);
+ if (fileData) {
+ acc.set(id, fileData);
+ }
+ return acc;
+ },
+ new Map(),
+ ),
+ erroredFiles: erroredFiles.reduce(
+ (acc: Map<FileId, BinaryFileData>, id) => {
+ const fileData = addedFiles.get(id);
+ if (fileData) {
+ acc.set(id, fileData);
+ }
+ return acc;
+ },
+ new Map(),
+ ),
+ };
+ },
+ });
+ this.excalidrawAPI = props.excalidrawAPI;
+ this.activeIntervalId = null;
+ this.idleTimeoutId = null;
+ }
+
+ private onUmmount: (() => void) | null = null;
+
+ componentDidMount() {
+ window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
+ window.addEventListener("online", this.onOfflineStatusToggle);
+ window.addEventListener("offline", this.onOfflineStatusToggle);
+ window.addEventListener(EVENT.UNLOAD, this.onUnload);
+
+ const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
+ this.portal.socket && this.portal.broadcastUserFollowed(payload);
+ });
+ const throttledRelayUserViewportBounds = throttleRAF(
+ this.relayVisibleSceneBounds,
+ );
+ const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() =>
+ throttledRelayUserViewportBounds(),
+ );
+ this.onUmmount = () => {
+ unsubOnUserFollow();
+ unsubOnScrollChange();
+ };
+
+ this.onOfflineStatusToggle();
+
+ const collabAPI: CollabAPI = {
+ isCollaborating: this.isCollaborating,
+ onPointerUpdate: this.onPointerUpdate,
+ startCollaboration: this.startCollaboration,
+ syncElements: this.syncElements,
+ fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
+ stopCollaboration: this.stopCollaboration,
+ setUsername: this.setUsername,
+ getUsername: this.getUsername,
+ getActiveRoomLink: this.getActiveRoomLink,
+ setCollabError: this.setErrorDialog,
+ };
+
+ appJotaiStore.set(collabAPIAtom, collabAPI);
+
+ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
+ window.collab = window.collab || ({} as Window["collab"]);
+ Object.defineProperties(window, {
+ collab: {
+ configurable: true,
+ value: this,
+ },
+ });
+ }
+ }
+
+ onOfflineStatusToggle = () => {
+ appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
+ };
+
+ componentWillUnmount() {
+ window.removeEventListener("online", this.onOfflineStatusToggle);
+ window.removeEventListener("offline", this.onOfflineStatusToggle);
+ window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
+ window.removeEventListener(EVENT.UNLOAD, this.onUnload);
+ window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
+ window.removeEventListener(
+ EVENT.VISIBILITY_CHANGE,
+ this.onVisibilityChange,
+ );
+ if (this.activeIntervalId) {
+ window.clearInterval(this.activeIntervalId);
+ this.activeIntervalId = null;
+ }
+ if (this.idleTimeoutId) {
+ window.clearTimeout(this.idleTimeoutId);
+ this.idleTimeoutId = null;
+ }
+ this.onUmmount?.();
+ }
+
+ isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
+
+ private setIsCollaborating = (isCollaborating: boolean) => {
+ appJotaiStore.set(isCollaboratingAtom, isCollaborating);
+ };
+
+ private onUnload = () => {
+ this.destroySocketClient({ isUnload: true });
+ };
+
+ private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
+ const syncableElements = getSyncableElements(
+ this.getSceneElementsIncludingDeleted(),
+ );
+
+ if (
+ this.isCollaborating() &&
+ (this.fileManager.shouldPreventUnload(syncableElements) ||
+ !isSavedToFirebase(this.portal, syncableElements))
+ ) {
+ // this won't run in time if user decides to leave the site, but
+ // the purpose is to run in immediately after user decides to stay
+ this.saveCollabRoomToFirebase(syncableElements);
+
+ preventUnload(event);
+ }
+ });
+
+ saveCollabRoomToFirebase = async (
+ syncableElements: readonly SyncableExcalidrawElement[],
+ ) => {
+ try {
+ const storedElements = await saveToFirebase(
+ this.portal,
+ syncableElements,
+ this.excalidrawAPI.getAppState(),
+ );
+
+ this.resetErrorIndicator();
+
+ if (this.isCollaborating() && storedElements) {
+ this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
+ }
+ } catch (error: any) {
+ const errorMessage = /is longer than.*?bytes/.test(error.message)
+ ? t("errors.collabSaveFailed_sizeExceeded")
+ : t("errors.collabSaveFailed");
+
+ if (
+ !this.state.dialogNotifiedErrors[errorMessage] ||
+ !this.isCollaborating()
+ ) {
+ this.setErrorDialog(errorMessage);
+ this.setState({
+ dialogNotifiedErrors: {
+ ...this.state.dialogNotifiedErrors,
+ [errorMessage]: true,
+ },
+ });
+ }
+
+ if (this.isCollaborating()) {
+ this.setErrorIndicator(errorMessage);
+ }
+
+ console.error(error);
+ }
+ };
+
+ stopCollaboration = (keepRemoteState = true) => {
+ this.queueBroadcastAllElements.cancel();
+ this.queueSaveToFirebase.cancel();
+ this.loadImageFiles.cancel();
+ this.resetErrorIndicator(true);
+
+ this.saveCollabRoomToFirebase(
+ getSyncableElements(
+ this.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ ),
+ );
+
+ if (this.portal.socket && this.fallbackInitializationHandler) {
+ this.portal.socket.off(
+ "connect_error",
+ this.fallbackInitializationHandler,
+ );
+ }
+
+ if (!keepRemoteState) {
+ LocalData.fileStorage.reset();
+ this.destroySocketClient();
+ } else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
+ // hack to ensure that we prefer we disregard any new browser state
+ // that could have been saved in other tabs while we were collaborating
+ resetBrowserStateVersions();
+
+ window.history.pushState({}, APP_NAME, window.location.origin);
+ this.destroySocketClient();
+
+ LocalData.fileStorage.reset();
+
+ const elements = this.excalidrawAPI
+ .getSceneElementsIncludingDeleted()
+ .map((element) => {
+ if (isImageElement(element) && element.status === "saved") {
+ return newElementWith(element, { status: "pending" });
+ }
+ return element;
+ });
+
+ this.excalidrawAPI.updateScene({
+ elements,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+ }
+ };
+
+ private destroySocketClient = (opts?: { isUnload: boolean }) => {
+ this.lastBroadcastedOrReceivedSceneVersion = -1;
+ this.portal.close();
+ this.fileManager.reset();
+ if (!opts?.isUnload) {
+ this.setIsCollaborating(false);
+ this.setActiveRoomLink(null);
+ this.collaborators = new Map();
+ this.excalidrawAPI.updateScene({
+ collaborators: this.collaborators,
+ });
+ LocalData.resumeSave("collaboration");
+ }
+ };
+
+ private fetchImageFilesFromFirebase = async (opts: {
+ elements: readonly ExcalidrawElement[];
+ /**
+ * Indicates whether to fetch files that are errored or pending and older
+ * than 10 seconds.
+ *
+ * Use this as a mechanism to fetch files which may be ok but for some
+ * reason their status was not updated correctly.
+ */
+ forceFetchFiles?: boolean;
+ }) => {
+ const unfetchedImages = opts.elements
+ .filter((element) => {
+ return (
+ isInitializedImageElement(element) &&
+ !this.fileManager.isFileTracked(element.fileId) &&
+ !element.isDeleted &&
+ (opts.forceFetchFiles
+ ? element.status !== "pending" ||
+ Date.now() - element.updated > 10000
+ : element.status === "saved")
+ );
+ })
+ .map((element) => (element as InitializedExcalidrawImageElement).fileId);
+
+ return await this.fileManager.getFiles(unfetchedImages);
+ };
+
+ private decryptPayload = async (
+ iv: Uint8Array,
+ encryptedData: ArrayBuffer,
+ decryptionKey: string,
+ ): Promise<ValueOf<SocketUpdateDataSource>> => {
+ try {
+ const decrypted = await decryptData(iv, encryptedData, decryptionKey);
+
+ const decodedData = new TextDecoder("utf-8").decode(
+ new Uint8Array(decrypted),
+ );
+ return JSON.parse(decodedData);
+ } catch (error) {
+ window.alert(t("alerts.decryptFailed"));
+ console.error(error);
+ return {
+ type: WS_SUBTYPES.INVALID_RESPONSE,
+ };
+ }
+ };
+
+ private fallbackInitializationHandler: null | (() => any) = null;
+
+ startCollaboration = async (
+ existingRoomLinkData: null | { roomId: string; roomKey: string },
+ ) => {
+ if (!this.state.username) {
+ import("@excalidraw/random-username").then(({ getRandomUsername }) => {
+ const username = getRandomUsername();
+ this.setUsername(username);
+ });
+ }
+
+ if (this.portal.socket) {
+ return null;
+ }
+
+ let roomId;
+ let roomKey;
+
+ if (existingRoomLinkData) {
+ ({ roomId, roomKey } = existingRoomLinkData);
+ } else {
+ ({ roomId, roomKey } = await generateCollaborationLinkData());
+ window.history.pushState(
+ {},
+ APP_NAME,
+ getCollaborationLink({ roomId, roomKey }),
+ );
+ }
+
+ // TODO: `ImportedDataState` type here seems abused
+ const scenePromise = resolvablePromise<
+ | (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
+ | null
+ >();
+
+ this.setIsCollaborating(true);
+ LocalData.pauseSave("collaboration");
+
+ const { default: socketIOClient } = await import(
+ /* webpackChunkName: "socketIoClient" */ "socket.io-client"
+ );
+
+ const fallbackInitializationHandler = () => {
+ this.initializeRoom({
+ roomLinkData: existingRoomLinkData,
+ fetchScene: true,
+ }).then((scene) => {
+ scenePromise.resolve(scene);
+ });
+ };
+ this.fallbackInitializationHandler = fallbackInitializationHandler;
+
+ try {
+ this.portal.socket = this.portal.open(
+ socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, {
+ transports: ["websocket", "polling"],
+ }),
+ roomId,
+ roomKey,
+ );
+
+ this.portal.socket.once("connect_error", fallbackInitializationHandler);
+ } catch (error: any) {
+ console.error(error);
+ this.setErrorDialog(error.message);
+ return null;
+ }
+
+ if (!existingRoomLinkData) {
+ const elements = this.excalidrawAPI.getSceneElements().map((element) => {
+ if (isImageElement(element) && element.status === "saved") {
+ return newElementWith(element, { status: "pending" });
+ }
+ return element;
+ });
+ // remove deleted elements from elements array to ensure we don't
+ // expose potentially sensitive user data in case user manually deletes
+ // existing elements (or clears scene), which would otherwise be persisted
+ // to database even if deleted before creating the room.
+ this.excalidrawAPI.updateScene({
+ elements,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+
+ this.saveCollabRoomToFirebase(getSyncableElements(elements));
+ }
+
+ // fallback in case you're not alone in the room but still don't receive
+ // initial SCENE_INIT message
+ this.socketInitializationTimer = window.setTimeout(
+ fallbackInitializationHandler,
+ INITIAL_SCENE_UPDATE_TIMEOUT,
+ );
+
+ // All socket listeners are moving to Portal
+ this.portal.socket.on(
+ "client-broadcast",
+ async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
+ if (!this.portal.roomKey) {
+ return;
+ }
+
+ const decryptedData = await this.decryptPayload(
+ iv,
+ encryptedData,
+ this.portal.roomKey,
+ );
+
+ switch (decryptedData.type) {
+ case WS_SUBTYPES.INVALID_RESPONSE:
+ return;
+ case WS_SUBTYPES.INIT: {
+ if (!this.portal.socketInitialized) {
+ this.initializeRoom({ fetchScene: false });
+ const remoteElements = decryptedData.payload.elements;
+ const reconciledElements =
+ this._reconcileElements(remoteElements);
+ this.handleRemoteSceneUpdate(reconciledElements);
+ // noop if already resolved via init from firebase
+ scenePromise.resolve({
+ elements: reconciledElements,
+ scrollToContent: true,
+ });
+ }
+ break;
+ }
+ case WS_SUBTYPES.UPDATE:
+ this.handleRemoteSceneUpdate(
+ this._reconcileElements(decryptedData.payload.elements),
+ );
+ break;
+ case WS_SUBTYPES.MOUSE_LOCATION: {
+ const { pointer, button, username, selectedElementIds } =
+ decryptedData.payload;
+
+ const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
+ decryptedData.payload.socketId ||
+ // @ts-ignore legacy, see #2094 (#2097)
+ decryptedData.payload.socketID;
+
+ this.updateCollaborator(socketId, {
+ pointer,
+ button,
+ selectedElementIds,
+ username,
+ });
+
+ break;
+ }
+
+ case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
+ const { sceneBounds, socketId } = decryptedData.payload;
+
+ const appState = this.excalidrawAPI.getAppState();
+
+ // we're not following the user
+ // (shouldn't happen, but could be late message or bug upstream)
+ if (appState.userToFollow?.socketId !== socketId) {
+ console.warn(
+ `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
+ );
+ return;
+ }
+
+ // cross-follow case, ignore updates in this case
+ if (
+ appState.userToFollow &&
+ appState.followedBy.has(appState.userToFollow.socketId)
+ ) {
+ return;
+ }
+
+ this.excalidrawAPI.updateScene({
+ appState: zoomToFitBounds({
+ appState,
+ bounds: sceneBounds,
+ fitToViewport: true,
+ viewportZoomFactor: 1,
+ }).appState,
+ });
+
+ break;
+ }
+
+ case WS_SUBTYPES.IDLE_STATUS: {
+ const { userState, socketId, username } = decryptedData.payload;
+ this.updateCollaborator(socketId, {
+ userState,
+ username,
+ });
+ break;
+ }
+
+ default: {
+ assertNever(decryptedData, null);
+ }
+ }
+ },
+ );
+
+ this.portal.socket.on("first-in-room", async () => {
+ if (this.portal.socket) {
+ this.portal.socket.off("first-in-room");
+ }
+ const sceneData = await this.initializeRoom({
+ fetchScene: true,
+ roomLinkData: existingRoomLinkData,
+ });
+ scenePromise.resolve(sceneData);
+ });
+
+ this.portal.socket.on(
+ WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
+ (followedBy: SocketId[]) => {
+ this.excalidrawAPI.updateScene({
+ appState: { followedBy: new Set(followedBy) },
+ });
+
+ this.relayVisibleSceneBounds({ force: true });
+ },
+ );
+
+ this.initializeIdleDetector();
+
+ this.setActiveRoomLink(window.location.href);
+
+ return scenePromise;
+ };
+
+ private initializeRoom = async ({
+ fetchScene,
+ roomLinkData,
+ }:
+ | {
+ fetchScene: true;
+ roomLinkData: { roomId: string; roomKey: string } | null;
+ }
+ | { fetchScene: false; roomLinkData?: null }) => {
+ clearTimeout(this.socketInitializationTimer!);
+ if (this.portal.socket && this.fallbackInitializationHandler) {
+ this.portal.socket.off(
+ "connect_error",
+ this.fallbackInitializationHandler,
+ );
+ }
+ if (fetchScene && roomLinkData && this.portal.socket) {
+ this.excalidrawAPI.resetScene();
+
+ try {
+ const elements = await loadFromFirebase(
+ roomLinkData.roomId,
+ roomLinkData.roomKey,
+ this.portal.socket,
+ );
+ if (elements) {
+ this.setLastBroadcastedOrReceivedSceneVersion(
+ getSceneVersion(elements),
+ );
+
+ return {
+ elements,
+ scrollToContent: true,
+ };
+ }
+ } catch (error: any) {
+ // log the error and move on. other peers will sync us the scene.
+ console.error(error);
+ } finally {
+ this.portal.socketInitialized = true;
+ }
+ } else {
+ this.portal.socketInitialized = true;
+ }
+ return null;
+ };
+
+ private _reconcileElements = (
+ remoteElements: readonly ExcalidrawElement[],
+ ): ReconciledExcalidrawElement[] => {
+ const localElements = this.getSceneElementsIncludingDeleted();
+ const appState = this.excalidrawAPI.getAppState();
+ const restoredRemoteElements = restoreElements(remoteElements, null);
+ const reconciledElements = reconcileElements(
+ localElements,
+ restoredRemoteElements as RemoteExcalidrawElement[],
+ appState,
+ );
+
+ // Avoid broadcasting to the rest of the collaborators the scene
+ // we just received!
+ // Note: this needs to be set before updating the scene as it
+ // synchronously calls render.
+ this.setLastBroadcastedOrReceivedSceneVersion(
+ getSceneVersion(reconciledElements),
+ );
+
+ return reconciledElements;
+ };
+
+ private loadImageFiles = throttle(async () => {
+ const { loadedFiles, erroredFiles } =
+ await this.fetchImageFilesFromFirebase({
+ elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ });
+
+ this.excalidrawAPI.addFiles(loadedFiles);
+
+ updateStaleImageStatuses({
+ excalidrawAPI: this.excalidrawAPI,
+ erroredFiles,
+ elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ });
+ }, LOAD_IMAGES_TIMEOUT);
+
+ private handleRemoteSceneUpdate = (
+ elements: ReconciledExcalidrawElement[],
+ ) => {
+ this.excalidrawAPI.updateScene({
+ elements,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+
+ this.loadImageFiles();
+ };
+
+ private onPointerMove = () => {
+ if (this.idleTimeoutId) {
+ window.clearTimeout(this.idleTimeoutId);
+ this.idleTimeoutId = null;
+ }
+
+ this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
+
+ if (!this.activeIntervalId) {
+ this.activeIntervalId = window.setInterval(
+ this.reportActive,
+ ACTIVE_THRESHOLD,
+ );
+ }
+ };
+
+ private onVisibilityChange = () => {
+ if (document.hidden) {
+ if (this.idleTimeoutId) {
+ window.clearTimeout(this.idleTimeoutId);
+ this.idleTimeoutId = null;
+ }
+ if (this.activeIntervalId) {
+ window.clearInterval(this.activeIntervalId);
+ this.activeIntervalId = null;
+ }
+ this.onIdleStateChange(UserIdleState.AWAY);
+ } else {
+ this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
+ this.activeIntervalId = window.setInterval(
+ this.reportActive,
+ ACTIVE_THRESHOLD,
+ );
+ this.onIdleStateChange(UserIdleState.ACTIVE);
+ }
+ };
+
+ private reportIdle = () => {
+ this.onIdleStateChange(UserIdleState.IDLE);
+ if (this.activeIntervalId) {
+ window.clearInterval(this.activeIntervalId);
+ this.activeIntervalId = null;
+ }
+ };
+
+ private reportActive = () => {
+ this.onIdleStateChange(UserIdleState.ACTIVE);
+ };
+
+ private initializeIdleDetector = () => {
+ document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
+ document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
+ };
+
+ setCollaborators(sockets: SocketId[]) {
+ const collaborators: InstanceType<typeof Collab>["collaborators"] =
+ new Map();
+ for (const socketId of sockets) {
+ collaborators.set(
+ socketId,
+ Object.assign({}, this.collaborators.get(socketId), {
+ isCurrentUser: socketId === this.portal.socket?.id,
+ }),
+ );
+ }
+ this.collaborators = collaborators;
+ this.excalidrawAPI.updateScene({ collaborators });
+ }
+
+ updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
+ const collaborators = new Map(this.collaborators);
+ const user: Mutable<Collaborator> = Object.assign(
+ {},
+ collaborators.get(socketId),
+ updates,
+ {
+ isCurrentUser: socketId === this.portal.socket?.id,
+ },
+ );
+ collaborators.set(socketId, user);
+ this.collaborators = collaborators;
+
+ this.excalidrawAPI.updateScene({
+ collaborators,
+ });
+ };
+
+ public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
+ this.lastBroadcastedOrReceivedSceneVersion = version;
+ };
+
+ public getLastBroadcastedOrReceivedSceneVersion = () => {
+ return this.lastBroadcastedOrReceivedSceneVersion;
+ };
+
+ public getSceneElementsIncludingDeleted = () => {
+ return this.excalidrawAPI.getSceneElementsIncludingDeleted();
+ };
+
+ onPointerUpdate = throttle(
+ (payload: {
+ pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
+ button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
+ pointersMap: Gesture["pointers"];
+ }) => {
+ payload.pointersMap.size < 2 &&
+ this.portal.socket &&
+ this.portal.broadcastMouseLocation(payload);
+ },
+ CURSOR_SYNC_TIMEOUT,
+ );
+
+ relayVisibleSceneBounds = (props?: { force: boolean }) => {
+ const appState = this.excalidrawAPI.getAppState();
+
+ if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
+ this.portal.broadcastVisibleSceneBounds(
+ {
+ sceneBounds: getVisibleSceneBounds(appState),
+ },
+ `follow@${this.portal.socket.id}`,
+ );
+ }
+ };
+
+ onIdleStateChange = (userState: UserIdleState) => {
+ this.portal.broadcastIdleChange(userState);
+ };
+
+ broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
+ if (
+ getSceneVersion(elements) >
+ this.getLastBroadcastedOrReceivedSceneVersion()
+ ) {
+ this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false);
+ this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
+ this.queueBroadcastAllElements();
+ }
+ };
+
+ syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
+ this.broadcastElements(elements);
+ this.queueSaveToFirebase();
+ };
+
+ queueBroadcastAllElements = throttle(() => {
+ this.portal.broadcastScene(
+ WS_SUBTYPES.UPDATE,
+ this.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ true,
+ );
+ const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
+ const newVersion = Math.max(
+ currentVersion,
+ getSceneVersion(this.getSceneElementsIncludingDeleted()),
+ );
+ this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
+ }, SYNC_FULL_SCENE_INTERVAL_MS);
+
+ queueSaveToFirebase = throttle(
+ () => {
+ if (this.portal.socketInitialized) {
+ this.saveCollabRoomToFirebase(
+ getSyncableElements(
+ this.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ ),
+ );
+ }
+ },
+ SYNC_FULL_SCENE_INTERVAL_MS,
+ { leading: false },
+ );
+
+ setUsername = (username: string) => {
+ this.setState({ username });
+ saveUsernameToLocalStorage(username);
+ };
+
+ getUsername = () => this.state.username;
+
+ setActiveRoomLink = (activeRoomLink: string | null) => {
+ this.setState({ activeRoomLink });
+ appJotaiStore.set(activeRoomLinkAtom, activeRoomLink);
+ };
+
+ getActiveRoomLink = () => this.state.activeRoomLink;
+
+ setErrorIndicator = (errorMessage: string | null) => {
+ appJotaiStore.set(collabErrorIndicatorAtom, {
+ message: errorMessage,
+ nonce: Date.now(),
+ });
+ };
+
+ resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
+ appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
+ if (resetDialogNotifiedErrors) {
+ this.setState({
+ dialogNotifiedErrors: {},
+ });
+ }
+ };
+
+ setErrorDialog = (errorMessage: string | null) => {
+ this.setState({
+ errorMessage,
+ });
+ };
+
+ render() {
+ const { errorMessage } = this.state;
+
+ return (
+ <>
+ {errorMessage != null && (
+ <ErrorDialog onClose={() => this.setErrorDialog(null)}>
+ {errorMessage}
+ </ErrorDialog>
+ )}
+ </>
+ );
+ }
+}
+
+declare global {
+ interface Window {
+ collab: InstanceType<typeof Collab>;
+ }
+}
+
+if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
+ window.collab = window.collab || ({} as Window["collab"]);
+}
+
+export default Collab;
+
+export type TCollabClass = Collab;
diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss
new file mode 100644
index 0000000..bb774d0
--- /dev/null
+++ b/excalidraw-app/collab/CollabError.scss
@@ -0,0 +1,35 @@
+@import "../../packages/excalidraw/css/variables.module.scss";
+
+.excalidraw {
+ .collab-errors-button {
+ width: 26px;
+ height: 26px;
+ margin-inline-end: 1rem;
+
+ color: var(--color-danger);
+
+ flex-shrink: 0;
+ }
+
+ .collab-errors-button-shake {
+ animation: strong-shake 0.15s 6;
+ }
+
+ @keyframes strong-shake {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 25% {
+ transform: rotate(10deg);
+ }
+ 50% {
+ transform: rotate(0deg);
+ }
+ 75% {
+ transform: rotate(-10deg);
+ }
+ 100% {
+ transform: rotate(0deg);
+ }
+ }
+}
diff --git a/excalidraw-app/collab/CollabError.tsx b/excalidraw-app/collab/CollabError.tsx
new file mode 100644
index 0000000..76828cf
--- /dev/null
+++ b/excalidraw-app/collab/CollabError.tsx
@@ -0,0 +1,54 @@
+import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
+import { warning } from "@excalidraw/excalidraw/components/icons";
+import clsx from "clsx";
+import { useEffect, useRef, useState } from "react";
+import { atom } from "../app-jotai";
+
+import "./CollabError.scss";
+
+type ErrorIndicator = {
+ message: string | null;
+ /** used to rerun the useEffect responsible for animation */
+ nonce: number;
+};
+
+export const collabErrorIndicatorAtom = atom<ErrorIndicator>({
+ message: null,
+ nonce: 0,
+});
+
+const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => {
+ const [isAnimating, setIsAnimating] = useState(false);
+ const clearAnimationRef = useRef<string | number>(0);
+
+ useEffect(() => {
+ setIsAnimating(true);
+ clearAnimationRef.current = window.setTimeout(() => {
+ setIsAnimating(false);
+ }, 1000);
+
+ return () => {
+ window.clearTimeout(clearAnimationRef.current);
+ };
+ }, [collabError.message, collabError.nonce]);
+
+ if (!collabError.message) {
+ return null;
+ }
+
+ return (
+ <Tooltip label={collabError.message} long={true}>
+ <div
+ className={clsx("collab-errors-button", {
+ "collab-errors-button-shake": isAnimating,
+ })}
+ >
+ {warning}
+ </div>
+ </Tooltip>
+ );
+};
+
+CollabError.displayName = "CollabError";
+
+export default CollabError;
diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx
new file mode 100644
index 0000000..1788d39
--- /dev/null
+++ b/excalidraw-app/collab/Portal.tsx
@@ -0,0 +1,256 @@
+import type {
+ SocketUpdateData,
+ SocketUpdateDataSource,
+ SyncableExcalidrawElement,
+} from "../data";
+import { isSyncableElement } from "../data";
+
+import type { TCollabClass } from "./Collab";
+
+import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
+import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
+import type {
+ OnUserFollowedPayload,
+ SocketId,
+} from "@excalidraw/excalidraw/types";
+import type { UserIdleState } from "@excalidraw/excalidraw/constants";
+import { trackEvent } from "@excalidraw/excalidraw/analytics";
+import throttle from "lodash.throttle";
+import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
+import { encryptData } from "@excalidraw/excalidraw/data/encryption";
+import type { Socket } from "socket.io-client";
+import { CaptureUpdateAction } from "@excalidraw/excalidraw";
+
+class Portal {
+ collab: TCollabClass;
+ socket: Socket | null = null;
+ socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
+ roomId: string | null = null;
+ roomKey: string | null = null;
+ broadcastedElementVersions: Map<string, number> = new Map();
+
+ constructor(collab: TCollabClass) {
+ this.collab = collab;
+ }
+
+ open(socket: Socket, id: string, key: string) {
+ this.socket = socket;
+ this.roomId = id;
+ this.roomKey = key;
+
+ // Initialize socket listeners
+ this.socket.on("init-room", () => {
+ if (this.socket) {
+ this.socket.emit("join-room", this.roomId);
+ trackEvent("share", "room joined");
+ }
+ });
+ this.socket.on("new-user", async (_socketId: string) => {
+ this.broadcastScene(
+ WS_SUBTYPES.INIT,
+ this.collab.getSceneElementsIncludingDeleted(),
+ /* syncAll */ true,
+ );
+ });
+ this.socket.on("room-user-change", (clients: SocketId[]) => {
+ this.collab.setCollaborators(clients);
+ });
+
+ return socket;
+ }
+
+ close() {
+ if (!this.socket) {
+ return;
+ }
+ this.queueFileUpload.flush();
+ this.socket.close();
+ this.socket = null;
+ this.roomId = null;
+ this.roomKey = null;
+ this.socketInitialized = false;
+ this.broadcastedElementVersions = new Map();
+ }
+
+ isOpen() {
+ return !!(
+ this.socketInitialized &&
+ this.socket &&
+ this.roomId &&
+ this.roomKey
+ );
+ }
+
+ async _broadcastSocketData(
+ data: SocketUpdateData,
+ volatile: boolean = false,
+ roomId?: string,
+ ) {
+ if (this.isOpen()) {
+ const json = JSON.stringify(data);
+ const encoded = new TextEncoder().encode(json);
+ const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded);
+
+ this.socket?.emit(
+ volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER,
+ roomId ?? this.roomId,
+ encryptedBuffer,
+ iv,
+ );
+ }
+ }
+
+ queueFileUpload = throttle(async () => {
+ try {
+ await this.collab.fileManager.saveFiles({
+ elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
+ files: this.collab.excalidrawAPI.getFiles(),
+ });
+ } catch (error: any) {
+ if (error.name !== "AbortError") {
+ this.collab.excalidrawAPI.updateScene({
+ appState: {
+ errorMessage: error.message,
+ },
+ });
+ }
+ }
+
+ let isChanged = false;
+ const newElements = this.collab.excalidrawAPI
+ .getSceneElementsIncludingDeleted()
+ .map((element) => {
+ if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
+ isChanged = true;
+ // this will signal collaborators to pull image data from server
+ // (using mutation instead of newElementWith otherwise it'd break
+ // in-progress dragging)
+ return newElementWith(element, { status: "saved" });
+ }
+ return element;
+ });
+
+ if (isChanged) {
+ this.collab.excalidrawAPI.updateScene({
+ elements: newElements,
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+ }
+ }, FILE_UPLOAD_TIMEOUT);
+
+ broadcastScene = async (
+ updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE,
+ elements: readonly OrderedExcalidrawElement[],
+ syncAll: boolean,
+ ) => {
+ if (updateType === WS_SUBTYPES.INIT && !syncAll) {
+ throw new Error("syncAll must be true when sending SCENE.INIT");
+ }
+
+ // sync out only the elements we think we need to to save bandwidth.
+ // periodically we'll resync the whole thing to make sure no one diverges
+ // due to a dropped message (server goes down etc).
+ const syncableElements = elements.reduce((acc, element) => {
+ if (
+ (syncAll ||
+ !this.broadcastedElementVersions.has(element.id) ||
+ element.version > this.broadcastedElementVersions.get(element.id)!) &&
+ isSyncableElement(element)
+ ) {
+ acc.push(element);
+ }
+ return acc;
+ }, [] as SyncableExcalidrawElement[]);
+
+ const data: SocketUpdateDataSource[typeof updateType] = {
+ type: updateType,
+ payload: {
+ elements: syncableElements,
+ },
+ };
+
+ for (const syncableElement of syncableElements) {
+ this.broadcastedElementVersions.set(
+ syncableElement.id,
+ syncableElement.version,
+ );
+ }
+
+ this.queueFileUpload();
+
+ await this._broadcastSocketData(data as SocketUpdateData);
+ };
+
+ broadcastIdleChange = (userState: UserIdleState) => {
+ if (this.socket?.id) {
+ const data: SocketUpdateDataSource["IDLE_STATUS"] = {
+ type: WS_SUBTYPES.IDLE_STATUS,
+ payload: {
+ socketId: this.socket.id as SocketId,
+ userState,
+ username: this.collab.state.username,
+ },
+ };
+ return this._broadcastSocketData(
+ data as SocketUpdateData,
+ true, // volatile
+ );
+ }
+ };
+
+ broadcastMouseLocation = (payload: {
+ pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
+ button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
+ }) => {
+ if (this.socket?.id) {
+ const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
+ type: WS_SUBTYPES.MOUSE_LOCATION,
+ payload: {
+ socketId: this.socket.id as SocketId,
+ pointer: payload.pointer,
+ button: payload.button || "up",
+ selectedElementIds:
+ this.collab.excalidrawAPI.getAppState().selectedElementIds,
+ username: this.collab.state.username,
+ },
+ };
+
+ return this._broadcastSocketData(
+ data as SocketUpdateData,
+ true, // volatile
+ );
+ }
+ };
+
+ broadcastVisibleSceneBounds = (
+ payload: {
+ sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"];
+ },
+ roomId: string,
+ ) => {
+ if (this.socket?.id) {
+ const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = {
+ type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS,
+ payload: {
+ socketId: this.socket.id as SocketId,
+ username: this.collab.state.username,
+ sceneBounds: payload.sceneBounds,
+ },
+ };
+
+ return this._broadcastSocketData(
+ data as SocketUpdateData,
+ true, // volatile
+ roomId,
+ );
+ }
+ };
+
+ broadcastUserFollowed = (payload: OnUserFollowedPayload) => {
+ if (this.socket?.id) {
+ this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
+ }
+ };
+}
+
+export default Portal;
diff --git a/excalidraw-app/components/AI.tsx b/excalidraw-app/components/AI.tsx
new file mode 100644
index 0000000..ba13849
--- /dev/null
+++ b/excalidraw-app/components/AI.tsx
@@ -0,0 +1,159 @@
+import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
+import {
+ DiagramToCodePlugin,
+ exportToBlob,
+ getTextFromElements,
+ MIME_TYPES,
+ TTDDialog,
+} from "@excalidraw/excalidraw";
+import { getDataURL } from "@excalidraw/excalidraw/data/blob";
+import { safelyParseJSON } from "@excalidraw/excalidraw/utils";
+
+export const AIComponents = ({
+ excalidrawAPI,
+}: {
+ excalidrawAPI: ExcalidrawImperativeAPI;
+}) => {
+ return (
+ <>
+ <DiagramToCodePlugin
+ generate={async ({ frame, children }) => {
+ const appState = excalidrawAPI.getAppState();
+
+ const blob = await exportToBlob({
+ elements: children,
+ appState: {
+ ...appState,
+ exportBackground: true,
+ viewBackgroundColor: appState.viewBackgroundColor,
+ },
+ exportingFrame: frame,
+ files: excalidrawAPI.getFiles(),
+ mimeType: MIME_TYPES.jpg,
+ });
+
+ const dataURL = await getDataURL(blob);
+
+ const textFromFrameChildren = getTextFromElements(children);
+
+ const response = await fetch(
+ `${
+ import.meta.env.VITE_APP_AI_BACKEND
+ }/v1/ai/diagram-to-code/generate`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ texts: textFromFrameChildren,
+ image: dataURL,
+ theme: appState.theme,
+ }),
+ },
+ );
+
+ if (!response.ok) {
+ const text = await response.text();
+ const errorJSON = safelyParseJSON(text);
+
+ if (!errorJSON) {
+ throw new Error(text);
+ }
+
+ if (errorJSON.statusCode === 429) {
+ return {
+ html: `<html>
+ <body style="margin: 0; text-align: center">
+ <div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px">
+ <div style="color:red">Too many requests today,</br>please try again tomorrow!</div>
+ </br>
+ </br>
+ <div>You can also try <a href="${
+ import.meta.env.VITE_APP_PLUS_LP
+ }/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">kj-diagramming cloud</a> to get more requests.</div>
+ </div>
+ </body>
+ </html>`,
+ };
+ }
+
+ throw new Error(errorJSON.message || text);
+ }
+
+ try {
+ const { html } = await response.json();
+
+ if (!html) {
+ throw new Error("Generation failed (invalid response)");
+ }
+ return {
+ html,
+ };
+ } catch (error: any) {
+ throw new Error("Generation failed (invalid response)");
+ }
+ }}
+ />
+
+ <TTDDialog
+ onTextSubmit={async (input) => {
+ try {
+ const response = await fetch(
+ `${
+ import.meta.env.VITE_APP_AI_BACKEND
+ }/v1/ai/text-to-diagram/generate`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ prompt: input }),
+ },
+ );
+
+ const rateLimit = response.headers.has("X-Ratelimit-Limit")
+ ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
+ : undefined;
+
+ const rateLimitRemaining = response.headers.has(
+ "X-Ratelimit-Remaining",
+ )
+ ? parseInt(
+ response.headers.get("X-Ratelimit-Remaining") || "0",
+ 10,
+ )
+ : undefined;
+
+ const json = await response.json();
+
+ if (!response.ok) {
+ if (response.status === 429) {
+ return {
+ rateLimit,
+ rateLimitRemaining,
+ error: new Error(
+ "Too many requests today, please try again tomorrow!",
+ ),
+ };
+ }
+
+ throw new Error(json.message || "Generation failed...");
+ }
+
+ const generatedResponse = json.generatedResponse;
+ if (!generatedResponse) {
+ throw new Error("Generation failed...");
+ }
+
+ return { generatedResponse, rateLimit, rateLimitRemaining };
+ } catch (err: any) {
+ throw new Error("Request failed");
+ }
+ }}
+ />
+ </>
+ );
+};
diff --git a/excalidraw-app/components/AppFooter.tsx b/excalidraw-app/components/AppFooter.tsx
new file mode 100644
index 0000000..e81df7c
--- /dev/null
+++ b/excalidraw-app/components/AppFooter.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import { Footer } from "@excalidraw/excalidraw/index";
+import { EncryptedIcon } from "./EncryptedIcon";
+import { ExcalidrawPlusAppLink } from "./ExcalidrawPlusAppLink";
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+import { DebugFooter, isVisualDebuggerEnabled } from "./DebugCanvas";
+
+export const AppFooter = React.memo(
+ ({ onChange }: { onChange: () => void }) => {
+ return (
+ <Footer>
+ <div
+ style={{
+ display: "flex",
+ gap: ".5rem",
+ alignItems: "center",
+ }}
+ >
+ {isVisualDebuggerEnabled() && <DebugFooter onChange={onChange} />}
+ {isExcalidrawPlusSignedUser ? (
+ <ExcalidrawPlusAppLink />
+ ) : (
+ <EncryptedIcon />
+ )}
+ </div>
+ </Footer>
+ );
+ },
+);
diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx
new file mode 100644
index 0000000..426ec69
--- /dev/null
+++ b/excalidraw-app/components/AppMainMenu.tsx
@@ -0,0 +1,86 @@
+import React from "react";
+import {
+ loginIcon,
+ ExcalLogo,
+ eyeIcon,
+} from "@excalidraw/excalidraw/components/icons";
+import type { Theme } from "@excalidraw/excalidraw/element/types";
+import { MainMenu } from "@excalidraw/excalidraw/index";
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+import { LanguageList } from "../app-language/LanguageList";
+import { saveDebugState } from "./DebugCanvas";
+
+export const AppMainMenu: React.FC<{
+ onCollabDialogOpen: () => any;
+ isCollaborating: boolean;
+ isCollabEnabled: boolean;
+ theme: Theme | "system";
+ setTheme: (theme: Theme | "system") => void;
+ refresh: () => void;
+}> = React.memo((props) => {
+ return (
+ <MainMenu>
+ <MainMenu.DefaultItems.LoadScene />
+ <MainMenu.DefaultItems.SaveToActiveFile />
+ <MainMenu.DefaultItems.Export />
+ <MainMenu.DefaultItems.SaveAsImage />
+ {props.isCollabEnabled && (
+ <MainMenu.DefaultItems.LiveCollaborationTrigger
+ isCollaborating={props.isCollaborating}
+ onSelect={() => props.onCollabDialogOpen()}
+ />
+ )}
+ <MainMenu.DefaultItems.CommandPalette className="highlighted" />
+ <MainMenu.DefaultItems.SearchMenu />
+ <MainMenu.DefaultItems.Help />
+ <MainMenu.DefaultItems.ClearCanvas />
+ <MainMenu.Separator />
+ <MainMenu.ItemLink
+ icon={ExcalLogo}
+ href={`${
+ import.meta.env.VITE_APP_PLUS_LP
+ }/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger`}
+ className=""
+ >
+ kj-diagramming cloud
+ </MainMenu.ItemLink>
+ <MainMenu.DefaultItems.Socials />
+ <MainMenu.ItemLink
+ icon={loginIcon}
+ href={`${import.meta.env.VITE_APP_PLUS_APP}${
+ isExcalidrawPlusSignedUser ? "" : "/sign-up"
+ }?utm_source=signin&utm_medium=app&utm_content=hamburger`}
+ className="highlighted"
+ >
+ {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"}
+ </MainMenu.ItemLink>
+ {import.meta.env.DEV && (
+ <MainMenu.Item
+ icon={eyeIcon}
+ onClick={() => {
+ if (window.visualDebug) {
+ delete window.visualDebug;
+ saveDebugState({ enabled: false });
+ } else {
+ window.visualDebug = { data: [] };
+ saveDebugState({ enabled: true });
+ }
+ props?.refresh();
+ }}
+ >
+ Visual Debug
+ </MainMenu.Item>
+ )}
+ <MainMenu.Separator />
+ <MainMenu.DefaultItems.ToggleTheme
+ allowSystemTheme
+ theme={props.theme}
+ onSelect={props.setTheme}
+ />
+ <MainMenu.ItemCustom>
+ <LanguageList style={{ width: "100%" }} />
+ </MainMenu.ItemCustom>
+ <MainMenu.DefaultItems.ChangeCanvasBackground />
+ </MainMenu>
+ );
+});
diff --git a/excalidraw-app/components/AppWelcomeScreen.tsx b/excalidraw-app/components/AppWelcomeScreen.tsx
new file mode 100644
index 0000000..139a951
--- /dev/null
+++ b/excalidraw-app/components/AppWelcomeScreen.tsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { loginIcon } from "@excalidraw/excalidraw/components/icons";
+import { useI18n } from "@excalidraw/excalidraw/i18n";
+import { WelcomeScreen } from "@excalidraw/excalidraw/index";
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+import { POINTER_EVENTS } from "@excalidraw/excalidraw/constants";
+
+export const AppWelcomeScreen: React.FC<{
+ onCollabDialogOpen: () => any;
+ isCollabEnabled: boolean;
+}> = React.memo((props) => {
+ const { t } = useI18n();
+ let headingContent;
+
+ if (isExcalidrawPlusSignedUser) {
+ headingContent = t("welcomeScreen.app.center_heading_plus")
+ .split(/(kj-diagramming)/)
+ .map((bit, idx) => {
+ if (bit === "kj-diagramming") {
+ return (
+ <a
+ style={{ pointerEvents: POINTER_EVENTS.inheritFromUI }}
+ href={`${
+ import.meta.env.VITE_APP_PLUS_APP
+ }?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
+ key={idx}
+ >
+ kj-diagramming
+ </a>
+ );
+ }
+ return bit;
+ });
+ } else {
+ headingContent = t("welcomeScreen.app.center_heading");
+ }
+
+ return (
+ <WelcomeScreen>
+ <WelcomeScreen.Hints.MenuHint>
+ {t("welcomeScreen.app.menuHint")}
+ </WelcomeScreen.Hints.MenuHint>
+ <WelcomeScreen.Hints.ToolbarHint />
+ <WelcomeScreen.Hints.HelpHint />
+ <WelcomeScreen.Center>
+ <WelcomeScreen.Center.Heading>
+ {headingContent}
+ </WelcomeScreen.Center.Heading>
+ <WelcomeScreen.Center.Menu>
+ <WelcomeScreen.Center.MenuItemLoadScene />
+ <WelcomeScreen.Center.MenuItemHelp />
+ {props.isCollabEnabled && (
+ <WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
+ onSelect={() => props.onCollabDialogOpen()}
+ />
+ )}
+ {!isExcalidrawPlusSignedUser && (
+ <WelcomeScreen.Center.MenuItemLink
+ href={`${
+ import.meta.env.VITE_APP_PLUS_LP
+ }/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest`}
+ shortcut={null}
+ icon={loginIcon}
+ >
+ Sign up
+ </WelcomeScreen.Center.MenuItemLink>
+ )}
+ </WelcomeScreen.Center.Menu>
+ </WelcomeScreen.Center>
+ </WelcomeScreen>
+ );
+});
diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx
new file mode 100644
index 0000000..31898ba
--- /dev/null
+++ b/excalidraw-app/components/DebugCanvas.tsx
@@ -0,0 +1,344 @@
+import { useCallback, useImperativeHandle, useRef } from "react";
+import { type AppState } from "@excalidraw/excalidraw/types";
+import { throttleRAF } from "@excalidraw/excalidraw/utils";
+import {
+ bootstrapCanvas,
+ getNormalizedCanvasDimensions,
+} from "@excalidraw/excalidraw/renderer/helpers";
+import type { DebugElement } from "@excalidraw/excalidraw/visualdebug";
+import {
+ ArrowheadArrowIcon,
+ CloseIcon,
+ TrashIcon,
+} from "@excalidraw/excalidraw/components/icons";
+import { STORAGE_KEYS } from "../app_constants";
+import type { Curve } from "../../packages/math";
+import {
+ isLineSegment,
+ type GlobalPoint,
+ type LineSegment,
+} from "../../packages/math";
+import { isCurve } from "../../packages/math/curve";
+
+const renderLine = (
+ context: CanvasRenderingContext2D,
+ zoom: number,
+ segment: LineSegment<GlobalPoint>,
+ color: string,
+) => {
+ context.save();
+ context.strokeStyle = color;
+ context.beginPath();
+ context.moveTo(segment[0][0] * zoom, segment[0][1] * zoom);
+ context.lineTo(segment[1][0] * zoom, segment[1][1] * zoom);
+ context.stroke();
+ context.restore();
+};
+
+const renderCubicBezier = (
+ context: CanvasRenderingContext2D,
+ zoom: number,
+ [start, control1, control2, end]: Curve<GlobalPoint>,
+ color: string,
+) => {
+ context.save();
+ context.strokeStyle = color;
+ context.beginPath();
+ context.moveTo(start[0] * zoom, start[1] * zoom);
+ context.bezierCurveTo(
+ control1[0] * zoom,
+ control1[1] * zoom,
+ control2[0] * zoom,
+ control2[1] * zoom,
+ end[0] * zoom,
+ end[1] * zoom,
+ );
+ context.stroke();
+ context.restore();
+};
+
+const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => {
+ context.strokeStyle = "#888";
+ context.save();
+ context.beginPath();
+ context.moveTo(-10 * zoom, -10 * zoom);
+ context.lineTo(10 * zoom, 10 * zoom);
+ context.moveTo(10 * zoom, -10 * zoom);
+ context.lineTo(-10 * zoom, 10 * zoom);
+ context.stroke();
+ context.save();
+};
+
+const render = (
+ frame: DebugElement[],
+ context: CanvasRenderingContext2D,
+ appState: AppState,
+) => {
+ frame.forEach((el: DebugElement) => {
+ switch (true) {
+ case isLineSegment(el.data):
+ renderLine(
+ context,
+ appState.zoom.value,
+ el.data as LineSegment<GlobalPoint>,
+ el.color,
+ );
+ break;
+ case isCurve(el.data):
+ renderCubicBezier(
+ context,
+ appState.zoom.value,
+ el.data as Curve<GlobalPoint>,
+ el.color,
+ );
+ break;
+ default:
+ throw new Error(`Unknown element type ${JSON.stringify(el)}`);
+ }
+ });
+};
+
+const _debugRenderer = (
+ canvas: HTMLCanvasElement,
+ appState: AppState,
+ scale: number,
+ refresh: () => void,
+) => {
+ const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
+ canvas,
+ scale,
+ );
+
+ if (appState.height !== canvas.height || appState.width !== canvas.width) {
+ refresh();
+ }
+
+ const context = bootstrapCanvas({
+ canvas,
+ scale,
+ normalizedWidth,
+ normalizedHeight,
+ viewBackgroundColor: "transparent",
+ });
+
+ // Apply zoom
+ context.save();
+ context.translate(
+ appState.scrollX * appState.zoom.value,
+ appState.scrollY * appState.zoom.value,
+ );
+
+ renderOrigin(context, appState.zoom.value);
+
+ if (
+ window.visualDebug?.currentFrame &&
+ window.visualDebug?.data &&
+ window.visualDebug.data.length > 0
+ ) {
+ // Render only one frame
+ const [idx] = debugFrameData();
+
+ render(window.visualDebug.data[idx], context, appState);
+ } else {
+ // Render all debug frames
+ window.visualDebug?.data.forEach((frame) => {
+ render(frame, context, appState);
+ });
+ }
+
+ if (window.visualDebug) {
+ window.visualDebug!.data =
+ window.visualDebug?.data.map((frame) =>
+ frame.filter((el) => el.permanent),
+ ) ?? [];
+ }
+};
+
+const debugFrameData = (): [number, number] => {
+ const currentFrame = window.visualDebug?.currentFrame ?? 0;
+ const frameCount = window.visualDebug?.data.length ?? 0;
+
+ if (frameCount > 0) {
+ return [currentFrame % frameCount, window.visualDebug?.currentFrame ?? 0];
+ }
+
+ return [0, 0];
+};
+
+export const saveDebugState = (debug: { enabled: boolean }) => {
+ try {
+ localStorage.setItem(
+ STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
+ JSON.stringify(debug),
+ );
+ } catch (error: any) {
+ console.error(error);
+ }
+};
+
+export const debugRenderer = throttleRAF(
+ (
+ canvas: HTMLCanvasElement,
+ appState: AppState,
+ scale: number,
+ refresh: () => void,
+ ) => {
+ _debugRenderer(canvas, appState, scale, refresh);
+ },
+ { trailing: true },
+);
+
+export const loadSavedDebugState = () => {
+ let debug;
+ try {
+ const savedDebugState = localStorage.getItem(
+ STORAGE_KEYS.LOCAL_STORAGE_DEBUG,
+ );
+ if (savedDebugState) {
+ debug = JSON.parse(savedDebugState) as { enabled: boolean };
+ }
+ } catch (error: any) {
+ console.error(error);
+ }
+
+ return debug ?? { enabled: false };
+};
+
+export const isVisualDebuggerEnabled = () =>
+ Array.isArray(window.visualDebug?.data);
+
+export const DebugFooter = ({ onChange }: { onChange: () => void }) => {
+ const moveForward = useCallback(() => {
+ if (
+ !window.visualDebug?.currentFrame ||
+ isNaN(window.visualDebug?.currentFrame ?? -1)
+ ) {
+ window.visualDebug!.currentFrame = 0;
+ }
+ window.visualDebug!.currentFrame += 1;
+ onChange();
+ }, [onChange]);
+ const moveBackward = useCallback(() => {
+ if (
+ !window.visualDebug?.currentFrame ||
+ isNaN(window.visualDebug?.currentFrame ?? -1) ||
+ window.visualDebug?.currentFrame < 1
+ ) {
+ window.visualDebug!.currentFrame = 1;
+ }
+ window.visualDebug!.currentFrame -= 1;
+ onChange();
+ }, [onChange]);
+ const reset = useCallback(() => {
+ window.visualDebug!.currentFrame = undefined;
+ onChange();
+ }, [onChange]);
+ const trashFrames = useCallback(() => {
+ if (window.visualDebug) {
+ window.visualDebug.currentFrame = undefined;
+ window.visualDebug.data = [];
+ }
+ onChange();
+ }, [onChange]);
+
+ return (
+ <>
+ <button
+ className="ToolIcon_type_button"
+ data-testid="debug-forward"
+ aria-label="Move forward"
+ type="button"
+ onClick={trashFrames}
+ >
+ <div
+ className="ToolIcon__icon"
+ aria-hidden="true"
+ aria-disabled="false"
+ >
+ {TrashIcon}
+ </div>
+ </button>
+ <button
+ className="ToolIcon_type_button"
+ data-testid="debug-forward"
+ aria-label="Move forward"
+ type="button"
+ onClick={moveBackward}
+ >
+ <div
+ className="ToolIcon__icon"
+ aria-hidden="true"
+ aria-disabled="false"
+ >
+ <ArrowheadArrowIcon flip />
+ </div>
+ </button>
+ <button
+ className="ToolIcon_type_button"
+ data-testid="debug-forward"
+ aria-label="Move forward"
+ type="button"
+ onClick={reset}
+ >
+ <div
+ className="ToolIcon__icon"
+ aria-hidden="true"
+ aria-disabled="false"
+ >
+ {CloseIcon}
+ </div>
+ </button>
+ <button
+ className="ToolIcon_type_button"
+ data-testid="debug-backward"
+ aria-label="Move backward"
+ type="button"
+ onClick={moveForward}
+ >
+ <div
+ className="ToolIcon__icon"
+ aria-hidden="true"
+ aria-disabled="false"
+ >
+ <ArrowheadArrowIcon />
+ </div>
+ </button>
+ </>
+ );
+};
+
+interface DebugCanvasProps {
+ appState: AppState;
+ scale: number;
+ ref?: React.Ref<HTMLCanvasElement>;
+}
+
+const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => {
+ const { width, height } = appState;
+
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ useImperativeHandle<HTMLCanvasElement | null, HTMLCanvasElement | null>(
+ ref,
+ () => canvasRef.current,
+ [canvasRef],
+ );
+
+ return (
+ <canvas
+ style={{
+ width,
+ height,
+ position: "absolute",
+ zIndex: 2,
+ pointerEvents: "none",
+ }}
+ width={width * scale}
+ height={height * scale}
+ ref={canvasRef}
+ >
+ Debug Canvas
+ </canvas>
+ );
+};
+
+export default DebugCanvas;
diff --git a/excalidraw-app/components/EncryptedIcon.tsx b/excalidraw-app/components/EncryptedIcon.tsx
new file mode 100644
index 0000000..a3a8417
--- /dev/null
+++ b/excalidraw-app/components/EncryptedIcon.tsx
@@ -0,0 +1,21 @@
+import { shield } from "@excalidraw/excalidraw/components/icons";
+import { Tooltip } from "@excalidraw/excalidraw/components/Tooltip";
+import { useI18n } from "@excalidraw/excalidraw/i18n";
+
+export const EncryptedIcon = () => {
+ const { t } = useI18n();
+
+ return (
+ <a
+ className="encrypted-icon tooltip"
+ href="https://plus.excalidraw.com/blog/end-to-end-encryption"
+ target="_blank"
+ rel="noopener noreferrer"
+ aria-label={t("encrypted.link")}
+ >
+ <Tooltip label={t("encrypted.tooltip")} long={true}>
+ {shield}
+ </Tooltip>
+ </a>
+ );
+};
diff --git a/excalidraw-app/components/ExcalidrawPlusAppLink.tsx b/excalidraw-app/components/ExcalidrawPlusAppLink.tsx
new file mode 100644
index 0000000..88cc0fc
--- /dev/null
+++ b/excalidraw-app/components/ExcalidrawPlusAppLink.tsx
@@ -0,0 +1,19 @@
+import { isExcalidrawPlusSignedUser } from "../app_constants";
+
+export const ExcalidrawPlusAppLink = () => {
+ if (!isExcalidrawPlusSignedUser) {
+ return null;
+ }
+ return (
+ <a
+ href={`${
+ import.meta.env.VITE_APP_PLUS_APP
+ }?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
+ target="_blank"
+ rel="noreferrer"
+ className="plus-button"
+ >
+ Go to kj-diagramming cloud
+ </a>
+ );
+};
diff --git a/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/excalidraw-app/components/ExportToExcalidrawPlus.tsx
new file mode 100644
index 0000000..782ecd9
--- /dev/null
+++ b/excalidraw-app/components/ExportToExcalidrawPlus.tsx
@@ -0,0 +1,133 @@
+import React from "react";
+import { Card } from "@excalidraw/excalidraw/components/Card";
+import { ToolButton } from "@excalidraw/excalidraw/components/ToolButton";
+import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
+import { loadFirebaseStorage, saveFilesToFirebase } from "../data/firebase";
+import type {
+ FileId,
+ NonDeletedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import type {
+ AppState,
+ BinaryFileData,
+ BinaryFiles,
+} from "@excalidraw/excalidraw/types";
+import { nanoid } from "nanoid";
+import { useI18n } from "@excalidraw/excalidraw/i18n";
+import {
+ encryptData,
+ generateEncryptionKey,
+} from "@excalidraw/excalidraw/data/encryption";
+import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
+import { FILE_UPLOAD_MAX_BYTES } from "../app_constants";
+import { encodeFilesForUpload } from "../data/FileManager";
+import { uploadBytes, ref } from "firebase/storage";
+import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
+import { trackEvent } from "@excalidraw/excalidraw/analytics";
+import { getFrame } from "@excalidraw/excalidraw/utils";
+import { ExcalidrawLogo } from "@excalidraw/excalidraw/components/ExcalidrawLogo";
+
+export const exportToExcalidrawPlus = async (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: Partial<AppState>,
+ files: BinaryFiles,
+ name: string,
+) => {
+ const storage = await loadFirebaseStorage();
+
+ const id = `${nanoid(12)}`;
+
+ const encryptionKey = (await generateEncryptionKey())!;
+ const encryptedData = await encryptData(
+ encryptionKey,
+ serializeAsJSON(elements, appState, files, "database"),
+ );
+
+ const blob = new Blob(
+ [encryptedData.iv, new Uint8Array(encryptedData.encryptedBuffer)],
+ {
+ type: MIME_TYPES.binary,
+ },
+ );
+
+ const storageRef = ref(storage, `/migrations/scenes/${id}`);
+ await uploadBytes(storageRef, blob, {
+ customMetadata: {
+ data: JSON.stringify({ version: 2, name }),
+ created: Date.now().toString(),
+ },
+ });
+
+ const filesMap = new Map<FileId, BinaryFileData>();
+ for (const element of elements) {
+ if (isInitializedImageElement(element) && files[element.fileId]) {
+ filesMap.set(element.fileId, files[element.fileId]);
+ }
+ }
+
+ if (filesMap.size) {
+ const filesToUpload = await encodeFilesForUpload({
+ files: filesMap,
+ encryptionKey,
+ maxBytes: FILE_UPLOAD_MAX_BYTES,
+ });
+
+ await saveFilesToFirebase({
+ prefix: `/migrations/files/scenes/${id}`,
+ files: filesToUpload,
+ });
+ }
+
+ window.open(
+ `${
+ import.meta.env.VITE_APP_PLUS_APP
+ }/import?excalidraw=${id},${encryptionKey}`,
+ );
+};
+
+export const ExportToExcalidrawPlus: React.FC<{
+ elements: readonly NonDeletedExcalidrawElement[];
+ appState: Partial<AppState>;
+ files: BinaryFiles;
+ name: string;
+ onError: (error: Error) => void;
+ onSuccess: () => void;
+}> = ({ elements, appState, files, name, onError, onSuccess }) => {
+ const { t } = useI18n();
+ return (
+ <Card color="primary">
+ <div className="Card-icon">
+ <ExcalidrawLogo
+ style={{
+ [`--color-logo-icon` as any]: "#fff",
+ width: "2.8rem",
+ height: "2.8rem",
+ }}
+ />
+ </div>
+ <h2>Excalidraw+</h2>
+ <div className="Card-details">
+ {t("exportDialog.excalidrawplus_description")}
+ </div>
+ <ToolButton
+ className="Card-button"
+ type="button"
+ title={t("exportDialog.excalidrawplus_button")}
+ aria-label={t("exportDialog.excalidrawplus_button")}
+ showAriaLabel={true}
+ onClick={async () => {
+ try {
+ trackEvent("export", "eplus", `ui (${getFrame()})`);
+ await exportToExcalidrawPlus(elements, appState, files, name);
+ onSuccess();
+ } catch (error: any) {
+ console.error(error);
+ if (error.name !== "AbortError") {
+ onError(new Error(t("exportDialog.excalidrawplus_exportError")));
+ }
+ }
+ }}
+ />
+ </Card>
+ );
+};
diff --git a/excalidraw-app/components/GitHubCorner.tsx b/excalidraw-app/components/GitHubCorner.tsx
new file mode 100644
index 0000000..4d74242
--- /dev/null
+++ b/excalidraw-app/components/GitHubCorner.tsx
@@ -0,0 +1,45 @@
+import oc from "open-color";
+import React from "react";
+import { THEME } from "@excalidraw/excalidraw/constants";
+import type { Theme } from "@excalidraw/excalidraw/element/types";
+
+// https://github.com/tholman/github-corners
+export const GitHubCorner = React.memo(
+ ({ theme, dir }: { theme: Theme; dir: string }) => (
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="40"
+ height="40"
+ viewBox="0 0 250 250"
+ className="rtl-mirror"
+ style={{
+ marginTop: "calc(var(--space-factor) * -1)",
+ [dir === "rtl" ? "marginLeft" : "marginRight"]:
+ "calc(var(--space-factor) * -1)",
+ }}
+ >
+ <a
+ href="https://github.com/excalidraw/excalidraw"
+ target="_blank"
+ rel="noopener noreferrer"
+ aria-label="GitHub repository"
+ >
+ <path
+ d="M0 0l115 115h15l12 27 108 108V0z"
+ fill={theme === THEME.LIGHT ? oc.gray[6] : oc.gray[7]}
+ />
+ <path
+ className="octo-arm"
+ d="M128 109c-15-9-9-19-9-19 3-7 2-11 2-11-1-7 3-2 3-2 4 5 2 11 2 11-3 10 5 15 9 16"
+ style={{ transformOrigin: "130px 106px" }}
+ fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
+ />
+ <path
+ className="octo-body"
+ d="M115 115s4 2 5 0l14-14c3-2 6-3 8-3-8-11-15-24 2-41 5-5 10-7 16-7 1-2 3-7 12-11 0 0 5 3 7 16 4 2 8 5 12 9s7 8 9 12c14 3 17 7 17 7-4 8-9 11-11 11 0 6-2 11-7 16-16 16-30 10-41 2 0 3-1 7-5 11l-12 11c-1 1 1 5 1 5z"
+ fill={theme === THEME.LIGHT ? oc.white : "var(--default-bg-color)"}
+ />
+ </a>
+ </svg>
+ ),
+);
diff --git a/excalidraw-app/components/TopErrorBoundary.tsx b/excalidraw-app/components/TopErrorBoundary.tsx
new file mode 100644
index 0000000..e7e00be
--- /dev/null
+++ b/excalidraw-app/components/TopErrorBoundary.tsx
@@ -0,0 +1,146 @@
+import React from "react";
+import * as Sentry from "@sentry/browser";
+import { t } from "@excalidraw/excalidraw/i18n";
+import Trans from "@excalidraw/excalidraw/components/Trans";
+
+interface TopErrorBoundaryState {
+ hasError: boolean;
+ sentryEventId: string;
+ localStorage: string;
+}
+
+export class TopErrorBoundary extends React.Component<
+ any,
+ TopErrorBoundaryState
+> {
+ state: TopErrorBoundaryState = {
+ hasError: false,
+ sentryEventId: "",
+ localStorage: "",
+ };
+
+ render() {
+ return this.state.hasError ? this.errorSplash() : this.props.children;
+ }
+
+ componentDidCatch(error: Error, errorInfo: any) {
+ const _localStorage: any = {};
+ for (const [key, value] of Object.entries({ ...localStorage })) {
+ try {
+ _localStorage[key] = JSON.parse(value);
+ } catch (error: any) {
+ _localStorage[key] = value;
+ }
+ }
+
+ Sentry.withScope((scope) => {
+ scope.setExtras(errorInfo);
+ const eventId = Sentry.captureException(error);
+
+ this.setState((state) => ({
+ hasError: true,
+ sentryEventId: eventId,
+ localStorage: JSON.stringify(_localStorage),
+ }));
+ });
+ }
+
+ private selectTextArea(event: React.MouseEvent<HTMLTextAreaElement>) {
+ if (event.target !== document.activeElement) {
+ event.preventDefault();
+ (event.target as HTMLTextAreaElement).select();
+ }
+ }
+
+ private async createGithubIssue() {
+ let body = "";
+ try {
+ const templateStrFn = (
+ await import(
+ /* webpackChunkName: "bug-issue-template" */ "../bug-issue-template"
+ )
+ ).default;
+ body = encodeURIComponent(templateStrFn(this.state.sentryEventId));
+ } catch (error: any) {
+ console.error(error);
+ }
+
+ window.open(
+ `https://github.com/excalidraw/excalidraw/issues/new?body=${body}`,
+ "_blank",
+ "noopener noreferrer",
+ );
+ }
+
+ private errorSplash() {
+ return (
+ <div className="ErrorSplash excalidraw">
+ <div className="ErrorSplash-messageContainer">
+ <div className="ErrorSplash-paragraph bigger align-center">
+ <Trans
+ i18nKey="errorSplash.headingMain"
+ button={(el) => (
+ <button onClick={() => window.location.reload()}>{el}</button>
+ )}
+ />
+ </div>
+ <div className="ErrorSplash-paragraph align-center">
+ <Trans
+ i18nKey="errorSplash.clearCanvasMessage"
+ button={(el) => (
+ <button
+ onClick={() => {
+ try {
+ localStorage.clear();
+ window.location.reload();
+ } catch (error: any) {
+ console.error(error);
+ }
+ }}
+ >
+ {el}
+ </button>
+ )}
+ />
+ <br />
+ <div className="smaller">
+ <span role="img" aria-label="warning">
+ ⚠️
+ </span>
+ {t("errorSplash.clearCanvasCaveat")}
+ <span role="img" aria-hidden="true">
+ ⚠️
+ </span>
+ </div>
+ </div>
+ <div>
+ <div className="ErrorSplash-paragraph">
+ {t("errorSplash.trackedToSentry", {
+ eventId: this.state.sentryEventId,
+ })}
+ </div>
+ <div className="ErrorSplash-paragraph">
+ <Trans
+ i18nKey="errorSplash.openIssueMessage"
+ button={(el) => (
+ <button onClick={() => this.createGithubIssue()}>{el}</button>
+ )}
+ />
+ </div>
+ <div className="ErrorSplash-paragraph">
+ <div className="ErrorSplash-details">
+ <label>{t("errorSplash.sceneContent")}</label>
+ <textarea
+ rows={5}
+ onPointerDown={this.selectTextArea}
+ readOnly={true}
+ value={this.state.localStorage}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts
new file mode 100644
index 0000000..f5f4eac
--- /dev/null
+++ b/excalidraw-app/data/FileManager.ts
@@ -0,0 +1,273 @@
+import { CaptureUpdateAction } from "@excalidraw/excalidraw";
+import { compressData } from "@excalidraw/excalidraw/data/encode";
+import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
+import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
+import type {
+ ExcalidrawElement,
+ ExcalidrawImageElement,
+ FileId,
+ InitializedExcalidrawImageElement,
+} from "@excalidraw/excalidraw/element/types";
+import { t } from "@excalidraw/excalidraw/i18n";
+import type {
+ BinaryFileData,
+ BinaryFileMetadata,
+ ExcalidrawImperativeAPI,
+ BinaryFiles,
+} from "@excalidraw/excalidraw/types";
+
+type FileVersion = Required<BinaryFileData>["version"];
+
+export class FileManager {
+ /** files being fetched */
+ private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
+ private erroredFiles_fetch = new Map<
+ ExcalidrawImageElement["fileId"],
+ true
+ >();
+ /** files being saved */
+ private savingFiles = new Map<
+ ExcalidrawImageElement["fileId"],
+ FileVersion
+ >();
+ /* files already saved to persistent storage */
+ private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
+ private erroredFiles_save = new Map<
+ ExcalidrawImageElement["fileId"],
+ FileVersion
+ >();
+
+ private _getFiles;
+ private _saveFiles;
+
+ constructor({
+ getFiles,
+ saveFiles,
+ }: {
+ getFiles: (fileIds: FileId[]) => Promise<{
+ loadedFiles: BinaryFileData[];
+ erroredFiles: Map<FileId, true>;
+ }>;
+ saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
+ savedFiles: Map<FileId, BinaryFileData>;
+ erroredFiles: Map<FileId, BinaryFileData>;
+ }>;
+ }) {
+ this._getFiles = getFiles;
+ this._saveFiles = saveFiles;
+ }
+
+ /**
+ * returns whether file is saved/errored, or being processed
+ */
+ isFileTracked = (id: FileId) => {
+ return (
+ this.savedFiles.has(id) ||
+ this.savingFiles.has(id) ||
+ this.fetchingFiles.has(id) ||
+ this.erroredFiles_fetch.has(id) ||
+ this.erroredFiles_save.has(id)
+ );
+ };
+
+ isFileSavedOrBeingSaved = (file: BinaryFileData) => {
+ const fileVersion = this.getFileVersion(file);
+ return (
+ this.savedFiles.get(file.id) === fileVersion ||
+ this.savingFiles.get(file.id) === fileVersion
+ );
+ };
+
+ getFileVersion = (file: BinaryFileData) => {
+ return file.version ?? 1;
+ };
+
+ saveFiles = async ({
+ elements,
+ files,
+ }: {
+ elements: readonly ExcalidrawElement[];
+ files: BinaryFiles;
+ }) => {
+ const addedFiles: Map<FileId, BinaryFileData> = new Map();
+
+ for (const element of elements) {
+ const fileData =
+ isInitializedImageElement(element) && files[element.fileId];
+
+ if (
+ fileData &&
+ // NOTE if errored during save, won't retry due to this check
+ !this.isFileSavedOrBeingSaved(fileData)
+ ) {
+ addedFiles.set(element.fileId, files[element.fileId]);
+ this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
+ }
+ }
+
+ try {
+ const { savedFiles, erroredFiles } = await this._saveFiles({
+ addedFiles,
+ });
+
+ for (const [fileId, fileData] of savedFiles) {
+ this.savedFiles.set(fileId, this.getFileVersion(fileData));
+ }
+
+ for (const [fileId, fileData] of erroredFiles) {
+ this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
+ }
+
+ return {
+ savedFiles,
+ erroredFiles,
+ };
+ } finally {
+ for (const [fileId] of addedFiles) {
+ this.savingFiles.delete(fileId);
+ }
+ }
+ };
+
+ getFiles = async (
+ ids: FileId[],
+ ): Promise<{
+ loadedFiles: BinaryFileData[];
+ erroredFiles: Map<FileId, true>;
+ }> => {
+ if (!ids.length) {
+ return {
+ loadedFiles: [],
+ erroredFiles: new Map(),
+ };
+ }
+ for (const id of ids) {
+ this.fetchingFiles.set(id, true);
+ }
+
+ try {
+ const { loadedFiles, erroredFiles } = await this._getFiles(ids);
+
+ for (const file of loadedFiles) {
+ this.savedFiles.set(file.id, this.getFileVersion(file));
+ }
+ for (const [fileId] of erroredFiles) {
+ this.erroredFiles_fetch.set(fileId, true);
+ }
+
+ return { loadedFiles, erroredFiles };
+ } finally {
+ for (const id of ids) {
+ this.fetchingFiles.delete(id);
+ }
+ }
+ };
+
+ /** a file element prevents unload only if it's being saved regardless of
+ * its `status`. This ensures that elements who for any reason haven't
+ * beed set to `saved` status don't prevent unload in future sessions.
+ * Technically we should prevent unload when the origin client haven't
+ * yet saved the `status` update to storage, but that should be taken care
+ * of during regular beforeUnload unsaved files check.
+ */
+ shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => {
+ return elements.some((element) => {
+ return (
+ isInitializedImageElement(element) &&
+ !element.isDeleted &&
+ this.savingFiles.has(element.fileId)
+ );
+ });
+ };
+
+ /**
+ * helper to determine if image element status needs updating
+ */
+ shouldUpdateImageElementStatus = (
+ element: ExcalidrawElement,
+ ): element is InitializedExcalidrawImageElement => {
+ return (
+ isInitializedImageElement(element) &&
+ this.savedFiles.has(element.fileId) &&
+ element.status === "pending"
+ );
+ };
+
+ reset() {
+ this.fetchingFiles.clear();
+ this.savingFiles.clear();
+ this.savedFiles.clear();
+ this.erroredFiles_fetch.clear();
+ this.erroredFiles_save.clear();
+ }
+}
+
+export const encodeFilesForUpload = async ({
+ files,
+ maxBytes,
+ encryptionKey,
+}: {
+ files: Map<FileId, BinaryFileData>;
+ maxBytes: number;
+ encryptionKey: string;
+}) => {
+ const processedFiles: {
+ id: FileId;
+ buffer: Uint8Array;
+ }[] = [];
+
+ for (const [id, fileData] of files) {
+ const buffer = new TextEncoder().encode(fileData.dataURL);
+
+ const encodedFile = await compressData<BinaryFileMetadata>(buffer, {
+ encryptionKey,
+ metadata: {
+ id,
+ mimeType: fileData.mimeType,
+ created: Date.now(),
+ lastRetrieved: Date.now(),
+ },
+ });
+
+ if (buffer.byteLength > maxBytes) {
+ throw new Error(
+ t("errors.fileTooBig", {
+ maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
+ }),
+ );
+ }
+
+ processedFiles.push({
+ id,
+ buffer: encodedFile,
+ });
+ }
+
+ return processedFiles;
+};
+
+export const updateStaleImageStatuses = (params: {
+ excalidrawAPI: ExcalidrawImperativeAPI;
+ erroredFiles: Map<FileId, true>;
+ elements: readonly ExcalidrawElement[];
+}) => {
+ if (!params.erroredFiles.size) {
+ return;
+ }
+ params.excalidrawAPI.updateScene({
+ elements: params.excalidrawAPI
+ .getSceneElementsIncludingDeleted()
+ .map((element) => {
+ if (
+ isInitializedImageElement(element) &&
+ params.erroredFiles.has(element.fileId)
+ ) {
+ return newElementWith(element, {
+ status: "error",
+ });
+ }
+ return element;
+ }),
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+};
diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts
new file mode 100644
index 0000000..3ab0ad9
--- /dev/null
+++ b/excalidraw-app/data/LocalData.ts
@@ -0,0 +1,258 @@
+/**
+ * This file deals with saving data state (appState, elements, images, ...)
+ * locally to the browser.
+ *
+ * Notes:
+ *
+ * - DataState refers to full state of the app: appState, elements, images,
+ * though some state is saved separately (collab username, library) for one
+ * reason or another. We also save different data to different storage
+ * (localStorage, indexedDB).
+ */
+
+import {
+ createStore,
+ entries,
+ del,
+ getMany,
+ set,
+ setMany,
+ get,
+} from "idb-keyval";
+import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
+import {
+ CANVAS_SEARCH_TAB,
+ DEFAULT_SIDEBAR,
+} from "@excalidraw/excalidraw/constants";
+import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
+import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
+import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
+import type {
+ ExcalidrawElement,
+ FileId,
+} from "@excalidraw/excalidraw/element/types";
+import type {
+ AppState,
+ BinaryFileData,
+ BinaryFiles,
+} from "@excalidraw/excalidraw/types";
+import type { MaybePromise } from "@excalidraw/excalidraw/utility-types";
+import { debounce } from "@excalidraw/excalidraw/utils";
+import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
+import { FileManager } from "./FileManager";
+import { Locker } from "./Locker";
+import { updateBrowserStateVersion } from "./tabSync";
+
+const filesStore = createStore("files-db", "files-store");
+
+class LocalFileManager extends FileManager {
+ clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
+ await entries(filesStore).then((entries) => {
+ for (const [id, imageData] of entries as [FileId, BinaryFileData][]) {
+ // if image is unused (not on canvas) & is older than 1 day, delete it
+ // from storage. We check `lastRetrieved` we care about the last time
+ // the image was used (loaded on canvas), not when it was initially
+ // created.
+ if (
+ (!imageData.lastRetrieved ||
+ Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) &&
+ !opts.currentFileIds.includes(id as FileId)
+ ) {
+ del(id, filesStore);
+ }
+ }
+ });
+ };
+}
+
+const saveDataStateToLocalStorage = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+) => {
+ try {
+ const _appState = clearAppStateForLocalStorage(appState);
+
+ if (
+ _appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
+ _appState.openSidebar.tab === CANVAS_SEARCH_TAB
+ ) {
+ _appState.openSidebar = null;
+ }
+
+ localStorage.setItem(
+ STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
+ JSON.stringify(clearElementsForLocalStorage(elements)),
+ );
+ localStorage.setItem(
+ STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
+ JSON.stringify(_appState),
+ );
+ updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
+ } catch (error: any) {
+ // Unable to access window.localStorage
+ console.error(error);
+ }
+};
+
+type SavingLockTypes = "collaboration";
+
+export class LocalData {
+ private static _save = debounce(
+ async (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ onFilesSaved: () => void,
+ ) => {
+ saveDataStateToLocalStorage(elements, appState);
+
+ await this.fileStorage.saveFiles({
+ elements,
+ files,
+ });
+ onFilesSaved();
+ },
+ SAVE_TO_LOCAL_STORAGE_TIMEOUT,
+ );
+
+ /** Saves DataState, including files. Bails if saving is paused */
+ static save = (
+ elements: readonly ExcalidrawElement[],
+ appState: AppState,
+ files: BinaryFiles,
+ onFilesSaved: () => void,
+ ) => {
+ // we need to make the `isSavePaused` check synchronously (undebounced)
+ if (!this.isSavePaused()) {
+ this._save(elements, appState, files, onFilesSaved);
+ }
+ };
+
+ static flushSave = () => {
+ this._save.flush();
+ };
+
+ private static locker = new Locker<SavingLockTypes>();
+
+ static pauseSave = (lockType: SavingLockTypes) => {
+ this.locker.lock(lockType);
+ };
+
+ static resumeSave = (lockType: SavingLockTypes) => {
+ this.locker.unlock(lockType);
+ };
+
+ static isSavePaused = () => {
+ return document.hidden || this.locker.isLocked();
+ };
+
+ // ---------------------------------------------------------------------------
+
+ static fileStorage = new LocalFileManager({
+ getFiles(ids) {
+ return getMany(ids, filesStore).then(
+ async (filesData: (BinaryFileData | undefined)[]) => {
+ const loadedFiles: BinaryFileData[] = [];
+ const erroredFiles = new Map<FileId, true>();
+
+ const filesToSave: [FileId, BinaryFileData][] = [];
+
+ filesData.forEach((data, index) => {
+ const id = ids[index];
+ if (data) {
+ const _data: BinaryFileData = {
+ ...data,
+ lastRetrieved: Date.now(),
+ };
+ filesToSave.push([id, _data]);
+ loadedFiles.push(_data);
+ } else {
+ erroredFiles.set(id, true);
+ }
+ });
+
+ try {
+ // save loaded files back to storage with updated `lastRetrieved`
+ setMany(filesToSave, filesStore);
+ } catch (error) {
+ console.warn(error);
+ }
+
+ return { loadedFiles, erroredFiles };
+ },
+ );
+ },
+ async saveFiles({ addedFiles }) {
+ const savedFiles = new Map<FileId, BinaryFileData>();
+ const erroredFiles = new Map<FileId, BinaryFileData>();
+
+ // before we use `storage` event synchronization, let's update the flag
+ // optimistically. Hopefully nothing fails, and an IDB read executed
+ // before an IDB write finishes will read the latest value.
+ updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
+
+ await Promise.all(
+ [...addedFiles].map(async ([id, fileData]) => {
+ try {
+ await set(id, fileData, filesStore);
+ savedFiles.set(id, fileData);
+ } catch (error: any) {
+ console.error(error);
+ erroredFiles.set(id, fileData);
+ }
+ }),
+ );
+
+ return { savedFiles, erroredFiles };
+ },
+ });
+}
+export class LibraryIndexedDBAdapter {
+ /** IndexedDB database and store name */
+ private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
+ /** library data store key */
+ private static key = "libraryData";
+
+ private static store = createStore(
+ `${LibraryIndexedDBAdapter.idb_name}-db`,
+ `${LibraryIndexedDBAdapter.idb_name}-store`,
+ );
+
+ static async load() {
+ const IDBData = await get<LibraryPersistedData>(
+ LibraryIndexedDBAdapter.key,
+ LibraryIndexedDBAdapter.store,
+ );
+
+ return IDBData || null;
+ }
+
+ static save(data: LibraryPersistedData): MaybePromise<void> {
+ return set(
+ LibraryIndexedDBAdapter.key,
+ data,
+ LibraryIndexedDBAdapter.store,
+ );
+ }
+}
+
+/** LS Adapter used only for migrating LS library data
+ * to indexedDB */
+export class LibraryLocalStorageMigrationAdapter {
+ static load() {
+ const LSData = localStorage.getItem(
+ STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
+ );
+ if (LSData != null) {
+ const libraryItems: ImportedDataState["libraryItems"] =
+ JSON.parse(LSData);
+ if (libraryItems) {
+ return { libraryItems };
+ }
+ }
+ return null;
+ }
+ static clear() {
+ localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
+ }
+}
diff --git a/excalidraw-app/data/Locker.ts b/excalidraw-app/data/Locker.ts
new file mode 100644
index 0000000..c99673e
--- /dev/null
+++ b/excalidraw-app/data/Locker.ts
@@ -0,0 +1,18 @@
+export class Locker<T extends string> {
+ private locks = new Map<T, true>();
+
+ lock = (lockType: T) => {
+ this.locks.set(lockType, true);
+ };
+
+ /** @returns whether no locks remaining */
+ unlock = (lockType: T) => {
+ this.locks.delete(lockType);
+ return !this.isLocked();
+ };
+
+ /** @returns whether some (or specific) locks are present */
+ isLocked(lockType?: T) {
+ return lockType ? this.locks.has(lockType) : !!this.locks.size;
+ }
+}
diff --git a/excalidraw-app/data/firebase.ts b/excalidraw-app/data/firebase.ts
new file mode 100644
index 0000000..a871b48
--- /dev/null
+++ b/excalidraw-app/data/firebase.ts
@@ -0,0 +1,313 @@
+import { reconcileElements } from "@excalidraw/excalidraw";
+import type {
+ ExcalidrawElement,
+ FileId,
+ OrderedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import { getSceneVersion } from "@excalidraw/excalidraw/element";
+import type Portal from "../collab/Portal";
+import { restoreElements } from "@excalidraw/excalidraw/data/restore";
+import type {
+ AppState,
+ BinaryFileData,
+ BinaryFileMetadata,
+ DataURL,
+} from "@excalidraw/excalidraw/types";
+import { FILE_CACHE_MAX_AGE_SEC } from "../app_constants";
+import { decompressData } from "@excalidraw/excalidraw/data/encode";
+import {
+ encryptData,
+ decryptData,
+} from "@excalidraw/excalidraw/data/encryption";
+import { MIME_TYPES } from "@excalidraw/excalidraw/constants";
+import type { SyncableExcalidrawElement } from ".";
+import { getSyncableElements } from ".";
+import { initializeApp } from "firebase/app";
+import {
+ getFirestore,
+ doc,
+ getDoc,
+ runTransaction,
+ Bytes,
+} from "firebase/firestore";
+import { getStorage, ref, uploadBytes } from "firebase/storage";
+import type { Socket } from "socket.io-client";
+import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
+
+// private
+// -----------------------------------------------------------------------------
+
+let FIREBASE_CONFIG: Record<string, any>;
+try {
+ FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
+} catch (error: any) {
+ console.warn(
+ `Error JSON parsing firebase config. Supplied value: ${
+ import.meta.env.VITE_APP_FIREBASE_CONFIG
+ }`,
+ );
+ FIREBASE_CONFIG = {};
+}
+
+let firebaseApp: ReturnType<typeof initializeApp> | null = null;
+let firestore: ReturnType<typeof getFirestore> | null = null;
+let firebaseStorage: ReturnType<typeof getStorage> | null = null;
+
+const _initializeFirebase = () => {
+ if (!firebaseApp) {
+ firebaseApp = initializeApp(FIREBASE_CONFIG);
+ }
+ return firebaseApp;
+};
+
+const _getFirestore = () => {
+ if (!firestore) {
+ firestore = getFirestore(_initializeFirebase());
+ }
+ return firestore;
+};
+
+const _getStorage = () => {
+ if (!firebaseStorage) {
+ firebaseStorage = getStorage(_initializeFirebase());
+ }
+ return firebaseStorage;
+};
+
+// -----------------------------------------------------------------------------
+
+export const loadFirebaseStorage = async () => {
+ return _getStorage();
+};
+
+type FirebaseStoredScene = {
+ sceneVersion: number;
+ iv: Bytes;
+ ciphertext: Bytes;
+};
+
+const encryptElements = async (
+ key: string,
+ elements: readonly ExcalidrawElement[],
+): Promise<{ ciphertext: ArrayBuffer; iv: Uint8Array }> => {
+ const json = JSON.stringify(elements);
+ const encoded = new TextEncoder().encode(json);
+ const { encryptedBuffer, iv } = await encryptData(key, encoded);
+
+ return { ciphertext: encryptedBuffer, iv };
+};
+
+const decryptElements = async (
+ data: FirebaseStoredScene,
+ roomKey: string,
+): Promise<readonly ExcalidrawElement[]> => {
+ const ciphertext = data.ciphertext.toUint8Array();
+ const iv = data.iv.toUint8Array();
+
+ const decrypted = await decryptData(iv, ciphertext, roomKey);
+ const decodedData = new TextDecoder("utf-8").decode(
+ new Uint8Array(decrypted),
+ );
+ return JSON.parse(decodedData);
+};
+
+class FirebaseSceneVersionCache {
+ private static cache = new WeakMap<Socket, number>();
+ static get = (socket: Socket) => {
+ return FirebaseSceneVersionCache.cache.get(socket);
+ };
+ static set = (
+ socket: Socket,
+ elements: readonly SyncableExcalidrawElement[],
+ ) => {
+ FirebaseSceneVersionCache.cache.set(socket, getSceneVersion(elements));
+ };
+}
+
+export const isSavedToFirebase = (
+ portal: Portal,
+ elements: readonly ExcalidrawElement[],
+): boolean => {
+ if (portal.socket && portal.roomId && portal.roomKey) {
+ const sceneVersion = getSceneVersion(elements);
+
+ return FirebaseSceneVersionCache.get(portal.socket) === sceneVersion;
+ }
+ // if no room exists, consider the room saved so that we don't unnecessarily
+ // prevent unload (there's nothing we could do at that point anyway)
+ return true;
+};
+
+export const saveFilesToFirebase = async ({
+ prefix,
+ files,
+}: {
+ prefix: string;
+ files: { id: FileId; buffer: Uint8Array }[];
+}) => {
+ const storage = await loadFirebaseStorage();
+
+ const erroredFiles: FileId[] = [];
+ const savedFiles: FileId[] = [];
+
+ await Promise.all(
+ files.map(async ({ id, buffer }) => {
+ try {
+ const storageRef = ref(storage, `${prefix}/${id}`);
+ await uploadBytes(storageRef, buffer, {
+ cacheControl: `public, max-age=${FILE_CACHE_MAX_AGE_SEC}`,
+ });
+ savedFiles.push(id);
+ } catch (error: any) {
+ erroredFiles.push(id);
+ }
+ }),
+ );
+
+ return { savedFiles, erroredFiles };
+};
+
+const createFirebaseSceneDocument = async (
+ elements: readonly SyncableExcalidrawElement[],
+ roomKey: string,
+) => {
+ const sceneVersion = getSceneVersion(elements);
+ const { ciphertext, iv } = await encryptElements(roomKey, elements);
+ return {
+ sceneVersion,
+ ciphertext: Bytes.fromUint8Array(new Uint8Array(ciphertext)),
+ iv: Bytes.fromUint8Array(iv),
+ } as FirebaseStoredScene;
+};
+
+export const saveToFirebase = async (
+ portal: Portal,
+ elements: readonly SyncableExcalidrawElement[],
+ appState: AppState,
+) => {
+ const { roomId, roomKey, socket } = portal;
+ if (
+ // bail if no room exists as there's nothing we can do at this point
+ !roomId ||
+ !roomKey ||
+ !socket ||
+ isSavedToFirebase(portal, elements)
+ ) {
+ return null;
+ }
+
+ const firestore = _getFirestore();
+ const docRef = doc(firestore, "scenes", roomId);
+
+ const storedScene = await runTransaction(firestore, async (transaction) => {
+ const snapshot = await transaction.get(docRef);
+
+ if (!snapshot.exists()) {
+ const storedScene = await createFirebaseSceneDocument(elements, roomKey);
+
+ transaction.set(docRef, storedScene);
+
+ return storedScene;
+ }
+
+ const prevStoredScene = snapshot.data() as FirebaseStoredScene;
+ const prevStoredElements = getSyncableElements(
+ restoreElements(await decryptElements(prevStoredScene, roomKey), null),
+ );
+ const reconciledElements = getSyncableElements(
+ reconcileElements(
+ elements,
+ prevStoredElements as OrderedExcalidrawElement[] as RemoteExcalidrawElement[],
+ appState,
+ ),
+ );
+
+ const storedScene = await createFirebaseSceneDocument(
+ reconciledElements,
+ roomKey,
+ );
+
+ transaction.update(docRef, storedScene);
+
+ // Return the stored elements as the in memory `reconciledElements` could have mutated in the meantime
+ return storedScene;
+ });
+
+ const storedElements = getSyncableElements(
+ restoreElements(await decryptElements(storedScene, roomKey), null),
+ );
+
+ FirebaseSceneVersionCache.set(socket, storedElements);
+
+ return storedElements;
+};
+
+export const loadFromFirebase = async (
+ roomId: string,
+ roomKey: string,
+ socket: Socket | null,
+): Promise<readonly SyncableExcalidrawElement[] | null> => {
+ const firestore = _getFirestore();
+ const docRef = doc(firestore, "scenes", roomId);
+ const docSnap = await getDoc(docRef);
+ if (!docSnap.exists()) {
+ return null;
+ }
+ const storedScene = docSnap.data() as FirebaseStoredScene;
+ const elements = getSyncableElements(
+ restoreElements(await decryptElements(storedScene, roomKey), null),
+ );
+
+ if (socket) {
+ FirebaseSceneVersionCache.set(socket, elements);
+ }
+
+ return elements;
+};
+
+export const loadFilesFromFirebase = async (
+ prefix: string,
+ decryptionKey: string,
+ filesIds: readonly FileId[],
+) => {
+ const loadedFiles: BinaryFileData[] = [];
+ const erroredFiles = new Map<FileId, true>();
+
+ await Promise.all(
+ [...new Set(filesIds)].map(async (id) => {
+ try {
+ const url = `https://firebasestorage.googleapis.com/v0/b/${
+ FIREBASE_CONFIG.storageBucket
+ }/o/${encodeURIComponent(prefix.replace(/^\//, ""))}%2F${id}`;
+ const response = await fetch(`${url}?alt=media`);
+ if (response.status < 400) {
+ const arrayBuffer = await response.arrayBuffer();
+
+ const { data, metadata } = await decompressData<BinaryFileMetadata>(
+ new Uint8Array(arrayBuffer),
+ {
+ decryptionKey,
+ },
+ );
+
+ const dataURL = new TextDecoder().decode(data) as DataURL;
+
+ loadedFiles.push({
+ mimeType: metadata.mimeType || MIME_TYPES.binary,
+ id,
+ dataURL,
+ created: metadata?.created || Date.now(),
+ lastRetrieved: metadata?.created || Date.now(),
+ });
+ } else {
+ erroredFiles.set(id, true);
+ }
+ } catch (error: any) {
+ erroredFiles.set(id, true);
+ console.error(error);
+ }
+ }),
+ );
+
+ return { loadedFiles, erroredFiles };
+};
diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts
new file mode 100644
index 0000000..6ecb98f
--- /dev/null
+++ b/excalidraw-app/data/index.ts
@@ -0,0 +1,338 @@
+import {
+ compressData,
+ decompressData,
+} from "@excalidraw/excalidraw/data/encode";
+import {
+ decryptData,
+ generateEncryptionKey,
+ IV_LENGTH_BYTES,
+} from "@excalidraw/excalidraw/data/encryption";
+import { serializeAsJSON } from "@excalidraw/excalidraw/data/json";
+import { restore } from "@excalidraw/excalidraw/data/restore";
+import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
+import type { SceneBounds } from "@excalidraw/excalidraw/element/bounds";
+import { isInvisiblySmallElement } from "@excalidraw/excalidraw/element/sizeHelpers";
+import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks";
+import type {
+ ExcalidrawElement,
+ FileId,
+ OrderedExcalidrawElement,
+} from "@excalidraw/excalidraw/element/types";
+import { t } from "@excalidraw/excalidraw/i18n";
+import type {
+ AppState,
+ BinaryFileData,
+ BinaryFiles,
+ SocketId,
+} from "@excalidraw/excalidraw/types";
+import type { UserIdleState } from "@excalidraw/excalidraw/constants";
+import type { MakeBrand } from "@excalidraw/excalidraw/utility-types";
+import { bytesToHexString } from "@excalidraw/excalidraw/utils";
+import type { WS_SUBTYPES } from "../app_constants";
+import {
+ DELETED_ELEMENT_TIMEOUT,
+ FILE_UPLOAD_MAX_BYTES,
+ ROOM_ID_BYTES,
+} from "../app_constants";
+import { encodeFilesForUpload } from "./FileManager";
+import { saveFilesToFirebase } from "./firebase";
+
+export type SyncableExcalidrawElement = OrderedExcalidrawElement &
+ MakeBrand<"SyncableExcalidrawElement">;
+
+export const isSyncableElement = (
+ element: OrderedExcalidrawElement,
+): element is SyncableExcalidrawElement => {
+ if (element.isDeleted) {
+ if (element.updated > Date.now() - DELETED_ELEMENT_TIMEOUT) {
+ return true;
+ }
+ return false;
+ }
+ return !isInvisiblySmallElement(element);
+};
+
+export const getSyncableElements = (
+ elements: readonly OrderedExcalidrawElement[],
+) =>
+ elements.filter((element) =>
+ isSyncableElement(element),
+ ) as SyncableExcalidrawElement[];
+
+const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
+const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
+
+const generateRoomId = async () => {
+ const buffer = new Uint8Array(ROOM_ID_BYTES);
+ window.crypto.getRandomValues(buffer);
+ return bytesToHexString(buffer);
+};
+
+export type EncryptedData = {
+ data: ArrayBuffer;
+ iv: Uint8Array;
+};
+
+export type SocketUpdateDataSource = {
+ INVALID_RESPONSE: {
+ type: WS_SUBTYPES.INVALID_RESPONSE;
+ };
+ SCENE_INIT: {
+ type: WS_SUBTYPES.INIT;
+ payload: {
+ elements: readonly ExcalidrawElement[];
+ };
+ };
+ SCENE_UPDATE: {
+ type: WS_SUBTYPES.UPDATE;
+ payload: {
+ elements: readonly ExcalidrawElement[];
+ };
+ };
+ MOUSE_LOCATION: {
+ type: WS_SUBTYPES.MOUSE_LOCATION;
+ payload: {
+ socketId: SocketId;
+ pointer: { x: number; y: number; tool: "pointer" | "laser" };
+ button: "down" | "up";
+ selectedElementIds: AppState["selectedElementIds"];
+ username: string;
+ };
+ };
+ USER_VISIBLE_SCENE_BOUNDS: {
+ type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS;
+ payload: {
+ socketId: SocketId;
+ username: string;
+ sceneBounds: SceneBounds;
+ };
+ };
+ IDLE_STATUS: {
+ type: WS_SUBTYPES.IDLE_STATUS;
+ payload: {
+ socketId: SocketId;
+ userState: UserIdleState;
+ username: string;
+ };
+ };
+};
+
+export type SocketUpdateDataIncoming =
+ SocketUpdateDataSource[keyof SocketUpdateDataSource];
+
+export type SocketUpdateData =
+ SocketUpdateDataSource[keyof SocketUpdateDataSource] & {
+ _brand: "socketUpdateData";
+ };
+
+const RE_COLLAB_LINK = /^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/;
+
+export const isCollaborationLink = (link: string) => {
+ const hash = new URL(link).hash;
+ return RE_COLLAB_LINK.test(hash);
+};
+
+export const getCollaborationLinkData = (link: string) => {
+ const hash = new URL(link).hash;
+ const match = hash.match(RE_COLLAB_LINK);
+ if (match && match[2].length !== 22) {
+ window.alert(t("alerts.invalidEncryptionKey"));
+ return null;
+ }
+ return match ? { roomId: match[1], roomKey: match[2] } : null;
+};
+
+export const generateCollaborationLinkData = async () => {
+ const roomId = await generateRoomId();
+ const roomKey = await generateEncryptionKey();
+
+ if (!roomKey) {
+ throw new Error("Couldn't generate room key");
+ }
+
+ return { roomId, roomKey };
+};
+
+export const getCollaborationLink = (data: {
+ roomId: string;
+ roomKey: string;
+}) => {
+ return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
+};
+
+/**
+ * Decodes shareLink data using the legacy buffer format.
+ * @deprecated
+ */
+const legacy_decodeFromBackend = async ({
+ buffer,
+ decryptionKey,
+}: {
+ buffer: ArrayBuffer;
+ decryptionKey: string;
+}) => {
+ let decrypted: ArrayBuffer;
+
+ try {
+ // Buffer should contain both the IV (fixed length) and encrypted data
+ const iv = buffer.slice(0, IV_LENGTH_BYTES);
+ const encrypted = buffer.slice(IV_LENGTH_BYTES, buffer.byteLength);
+ decrypted = await decryptData(new Uint8Array(iv), encrypted, decryptionKey);
+ } catch (error: any) {
+ // Fixed IV (old format, backward compatibility)
+ const fixedIv = new Uint8Array(IV_LENGTH_BYTES);
+ decrypted = await decryptData(fixedIv, buffer, decryptionKey);
+ }
+
+ // We need to convert the decrypted array buffer to a string
+ const string = new window.TextDecoder("utf-8").decode(
+ new Uint8Array(decrypted),
+ );
+ const data: ImportedDataState = JSON.parse(string);
+
+ return {
+ elements: data.elements || null,
+ appState: data.appState || null,
+ };
+};
+
+const importFromBackend = async (
+ id: string,
+ decryptionKey: string,
+): Promise<ImportedDataState> => {
+ try {
+ const response = await fetch(`${BACKEND_V2_GET}${id}`);
+
+ if (!response.ok) {
+ window.alert(t("alerts.importBackendFailed"));
+ return {};
+ }
+ const buffer = await response.arrayBuffer();
+
+ try {
+ const { data: decodedBuffer } = await decompressData(
+ new Uint8Array(buffer),
+ {
+ decryptionKey,
+ },
+ );
+ const data: ImportedDataState = JSON.parse(
+ new TextDecoder().decode(decodedBuffer),
+ );
+
+ return {
+ elements: data.elements || null,
+ appState: data.appState || null,
+ };
+ } catch (error: any) {
+ console.warn(
+ "error when decoding shareLink data using the new format:",
+ error,
+ );
+ return legacy_decodeFromBackend({ buffer, decryptionKey });
+ }
+ } catch (error: any) {
+ window.alert(t("alerts.importBackendFailed"));
+ console.error(error);
+ return {};
+ }
+};
+
+export const loadScene = async (
+ id: string | null,
+ privateKey: string | null,
+ // Supply local state even if importing from backend to ensure we restore
+ // localStorage user settings which we do not persist on server.
+ // Non-optional so we don't forget to pass it even if `undefined`.
+ localDataState: ImportedDataState | undefined | null,
+) => {
+ let data;
+ if (id != null && privateKey != null) {
+ // the private key is used to decrypt the content from the server, take
+ // extra care not to leak it
+ data = restore(
+ await importFromBackend(id, privateKey),
+ localDataState?.appState,
+ localDataState?.elements,
+ { repairBindings: true, refreshDimensions: false },
+ );
+ } else {
+ data = restore(localDataState || null, null, null, {
+ repairBindings: true,
+ });
+ }
+
+ return {
+ elements: data.elements,
+ appState: data.appState,
+ // note: this will always be empty because we're not storing files
+ // in the scene database/localStorage, and instead fetch them async
+ // from a different database
+ files: data.files,
+ };
+};
+
+type ExportToBackendResult =
+ | { url: null; errorMessage: string }
+ | { url: string; errorMessage: null };
+
+export const exportToBackend = async (
+ elements: readonly ExcalidrawElement[],
+ appState: Partial<AppState>,
+ files: BinaryFiles,
+): Promise<ExportToBackendResult> => {
+ const encryptionKey = await generateEncryptionKey("string");
+
+ const payload = await compressData(
+ new TextEncoder().encode(
+ serializeAsJSON(elements, appState, files, "database"),
+ ),
+ { encryptionKey },
+ );
+
+ try {
+ const filesMap = new Map<FileId, BinaryFileData>();
+ for (const element of elements) {
+ if (isInitializedImageElement(element) && files[element.fileId]) {
+ filesMap.set(element.fileId, files[element.fileId]);
+ }
+ }
+
+ const filesToUpload = await encodeFilesForUpload({
+ files: filesMap,
+ encryptionKey,
+ maxBytes: FILE_UPLOAD_MAX_BYTES,
+ });
+
+ const response = await fetch(BACKEND_V2_POST, {
+ method: "POST",
+ body: payload.buffer,
+ });
+ const json = await response.json();
+ if (json.id) {
+ const url = new URL(window.location.href);
+ // We need to store the key (and less importantly the id) as hash instead
+ // of queryParam in order to never send it to the server
+ url.hash = `json=${json.id},${encryptionKey}`;
+ const urlString = url.toString();
+
+ await saveFilesToFirebase({
+ prefix: `/files/shareLinks/${json.id}`,
+ files: filesToUpload,
+ });
+
+ return { url: urlString, errorMessage: null };
+ } else if (json.error_class === "RequestTooLargeError") {
+ return {
+ url: null,
+ errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
+ };
+ }
+
+ return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
+ } catch (error: any) {
+ console.error(error);
+
+ return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
+ }
+};
diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts
new file mode 100644
index 0000000..640dfdb
--- /dev/null
+++ b/excalidraw-app/data/localStorage.ts
@@ -0,0 +1,99 @@
+import type { ExcalidrawElement } from "@excalidraw/excalidraw/element/types";
+import type { AppState } from "@excalidraw/excalidraw/types";
+import {
+ clearAppStateForLocalStorage,
+ getDefaultAppState,
+} from "@excalidraw/excalidraw/appState";
+import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element";
+import { STORAGE_KEYS } from "../app_constants";
+
+export const saveUsernameToLocalStorage = (username: string) => {
+ try {
+ localStorage.setItem(
+ STORAGE_KEYS.LOCAL_STORAGE_COLLAB,
+ JSON.stringify({ username }),
+ );
+ } catch (error: any) {
+ // Unable to access window.localStorage
+ console.error(error);
+ }
+};
+
+export const importUsernameFromLocalStorage = (): string | null => {
+ try {
+ const data = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
+ if (data) {
+ return JSON.parse(data).username;
+ }
+ } catch (error: any) {
+ // Unable to access localStorage
+ console.error(error);
+ }
+
+ return null;
+};
+
+export const importFromLocalStorage = () => {
+ let savedElements = null;
+ let savedState = null;
+
+ try {
+ savedElements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
+ savedState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
+ } catch (error: any) {
+ // Unable to access localStorage
+ console.error(error);
+ }
+
+ let elements: ExcalidrawElement[] = [];
+ if (savedElements) {
+ try {
+ elements = clearElementsForLocalStorage(JSON.parse(savedElements));
+ } catch (error: any) {
+ console.error(error);
+ // Do nothing because elements array is already empty
+ }
+ }
+
+ let appState = null;
+ if (savedState) {
+ try {
+ appState = {
+ ...getDefaultAppState(),
+ ...clearAppStateForLocalStorage(
+ JSON.parse(savedState) as Partial<AppState>,
+ ),
+ };
+ } catch (error: any) {
+ console.error(error);
+ // Do nothing because appState is already null
+ }
+ }
+ return { elements, appState };
+};
+
+export const getElementsStorageSize = () => {
+ try {
+ const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS);
+ const elementsSize = elements?.length || 0;
+ return elementsSize;
+ } catch (error: any) {
+ console.error(error);
+ return 0;
+ }
+};
+
+export const getTotalStorageSize = () => {
+ try {
+ const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE);
+ const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB);
+
+ const appStateSize = appState?.length || 0;
+ const collabSize = collab?.length || 0;
+
+ return appStateSize + collabSize + getElementsStorageSize();
+ } catch (error: any) {
+ console.error(error);
+ return 0;
+ }
+};
diff --git a/excalidraw-app/data/tabSync.ts b/excalidraw-app/data/tabSync.ts
new file mode 100644
index 0000000..0617a55
--- /dev/null
+++ b/excalidraw-app/data/tabSync.ts
@@ -0,0 +1,39 @@
+import { STORAGE_KEYS } from "../app_constants";
+
+// in-memory state (this tab's current state) versions. Currently just
+// timestamps of the last time the state was saved to browser storage.
+const LOCAL_STATE_VERSIONS = {
+ [STORAGE_KEYS.VERSION_DATA_STATE]: -1,
+ [STORAGE_KEYS.VERSION_FILES]: -1,
+};
+
+type BrowserStateTypes = keyof typeof LOCAL_STATE_VERSIONS;
+
+export const isBrowserStorageStateNewer = (type: BrowserStateTypes) => {
+ const storageTimestamp = JSON.parse(localStorage.getItem(type) || "-1");
+ return storageTimestamp > LOCAL_STATE_VERSIONS[type];
+};
+
+export const updateBrowserStateVersion = (type: BrowserStateTypes) => {
+ const timestamp = Date.now();
+ try {
+ localStorage.setItem(type, JSON.stringify(timestamp));
+ LOCAL_STATE_VERSIONS[type] = timestamp;
+ } catch (error) {
+ console.error("error while updating browser state verison", error);
+ }
+};
+
+export const resetBrowserStateVersions = () => {
+ try {
+ for (const key of Object.keys(
+ LOCAL_STATE_VERSIONS,
+ ) as BrowserStateTypes[]) {
+ const timestamp = -1;
+ localStorage.setItem(key, JSON.stringify(timestamp));
+ LOCAL_STATE_VERSIONS[key] = timestamp;
+ }
+ } catch (error) {
+ console.error("error while resetting browser state verison", error);
+ }
+};
diff --git a/excalidraw-app/debug.ts b/excalidraw-app/debug.ts
new file mode 100644
index 0000000..38ba805
--- /dev/null
+++ b/excalidraw-app/debug.ts
@@ -0,0 +1,135 @@
+declare global {
+ interface Window {
+ debug: typeof Debug;
+ }
+}
+
+const lessPrecise = (num: number, precision = 5) =>
+ parseFloat(num.toPrecision(precision));
+
+const getAvgFrameTime = (times: number[]) =>
+ lessPrecise(times.reduce((a, b) => a + b) / times.length);
+
+const getFps = (frametime: number) => lessPrecise(1000 / frametime);
+
+export class Debug {
+ public static DEBUG_LOG_TIMES = true;
+
+ private static TIMES_AGGR: Record<string, { t: number; times: number[] }> =
+ {};
+ private static TIMES_AVG: Record<
+ string,
+ { t: number; times: number[]; avg: number | null }
+ > = {};
+ private static LAST_DEBUG_LOG_CALL = 0;
+ private static DEBUG_LOG_INTERVAL_ID: null | number = null;
+
+ private static setupInterval = () => {
+ if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
+ console.info("%c(starting perf recording)", "color: lime");
+ Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
+ }
+ Debug.LAST_DEBUG_LOG_CALL = Date.now();
+ };
+
+ private static debugLogger = () => {
+ if (
+ Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
+ Debug.DEBUG_LOG_INTERVAL_ID !== null
+ ) {
+ window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
+ Debug.DEBUG_LOG_INTERVAL_ID = null;
+ for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
+ if (avg != null) {
+ console.info(
+ `%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
+ "color: blue",
+ );
+ }
+ }
+ console.info("%c(stopping perf recording)", "color: red");
+ Debug.TIMES_AGGR = {};
+ Debug.TIMES_AVG = {};
+ return;
+ }
+ if (Debug.DEBUG_LOG_TIMES) {
+ for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
+ if (times.length) {
+ console.info(
+ name,
+ lessPrecise(times.reduce((a, b) => a + b)),
+ times.sort((a, b) => a - b).map((x) => lessPrecise(x)),
+ );
+ Debug.TIMES_AGGR[name] = { t, times: [] };
+ }
+ }
+ for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
+ if (times.length) {
+ const avgFrameTime = getAvgFrameTime(times);
+ console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
+ Debug.TIMES_AVG[name] = {
+ t,
+ times: [],
+ avg:
+ avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime,
+ };
+ }
+ }
+ }
+ };
+
+ public static logTime = (time?: number, name = "default") => {
+ Debug.setupInterval();
+ const now = performance.now();
+ const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || {
+ t: 0,
+ times: [],
+ });
+ if (t) {
+ times.push(time != null ? time : now - t);
+ }
+ Debug.TIMES_AGGR[name].t = now;
+ };
+ public static logTimeAverage = (time?: number, name = "default") => {
+ Debug.setupInterval();
+ const now = performance.now();
+ const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || {
+ t: 0,
+ times: [],
+ });
+ if (t) {
+ times.push(time != null ? time : now - t);
+ }
+ Debug.TIMES_AVG[name].t = now;
+ };
+
+ private static logWrapper =
+ (type: "logTime" | "logTimeAverage") =>
+ <T extends any[], R>(fn: (...args: T) => R, name = "default") => {
+ return (...args: T) => {
+ const t0 = performance.now();
+ const ret = fn(...args);
+ Debug.logTime(performance.now() - t0, name);
+ return ret;
+ };
+ };
+
+ public static logTimeWrap = Debug.logWrapper("logTime");
+ public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage");
+
+ public static perfWrap = <T extends any[], R>(
+ fn: (...args: T) => R,
+ name = "default",
+ ) => {
+ return (...args: T) => {
+ // eslint-disable-next-line no-console
+ console.time(name);
+ const ret = fn(...args);
+ // eslint-disable-next-line no-console
+ console.timeEnd(name);
+ return ret;
+ };
+ };
+}
+//@ts-ignore
+window.debug = Debug;
diff --git a/excalidraw-app/global.d.ts b/excalidraw-app/global.d.ts
new file mode 100644
index 0000000..92b7c08
--- /dev/null
+++ b/excalidraw-app/global.d.ts
@@ -0,0 +1,6 @@
+import "@excalidraw/excalidraw/global";
+import "@excalidraw/excalidraw/css";
+
+interface Window {
+ __EXCALIDRAW_SHA__: string | undefined;
+}
diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html
new file mode 100644
index 0000000..0e7772f
--- /dev/null
+++ b/excalidraw-app/index.html
@@ -0,0 +1,252 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>kj-diagramming | Hand-drawn look & feel • Collaborative • Secure</title>
+ <meta
+ name="viewport"
+ content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no"
+ />
+ <meta name="referrer" content="origin" />
+ <meta name="mobile-web-app-capable" content="yes" />
+ <meta name="theme-color" content="#121212" />
+
+ <!-- Primary Meta Tags -->
+ <meta
+ name="title"
+ content="kj-diagramming — Collaborative whiteboarding made easy"
+ />
+ <meta
+ name="description"
+ content="kj-diagramming is a virtual collaborative whiteboard tool (an excalidraw fork) that lets you easily sketch diagrams with a hand-drawn feel."
+ />
+ <meta name="image" content="/og-image-3.png" />
+
+ <!-- Open Graph / Facebook -->
+ <meta property="og:site_name" content="kj-diagramming" />
+ <meta property="og:type" content="website" />
+ <meta property="og:url" content="/" />
+ <meta
+ property="og:title"
+ content="kj-diagramming — Collaborative whiteboarding made easy"
+ />
+ <meta property="og:image:alt" content="kj-diagramming logo" />
+ <meta
+ property="og:description"
+ content="kj-diagramming is a virtual collaborative whiteboard tool (an excalidraw fork) that lets you easily sketch diagrams with a hand-drawn feel."
+ />
+ <meta property="og:image" content="/og-image-3.png" />
+
+ <!-- Twitter -->
+ <meta property="twitter:card" content="summary_large_image" />
+ <meta property="twitter:site" content="@kjdiagramming" />
+ <meta property="twitter:url" content="/" />
+ <meta
+ property="twitter:title"
+ content="kj-diagramming — Collaborative whiteboarding made easy"
+ />
+ <meta
+ property="twitter:description"
+ content="kj-diagramming is a virtual collaborative whiteboard tool (an excalidraw fork) that lets you easily sketch diagrams with a hand-drawn feel."
+ />
+ <meta
+ property="twitter:image"
+ content="/og-image-3.png"
+ />
+
+ <link rel="canonical" href="/" />
+
+ <!------------------------------------------------------------------------->
+ <!-- to minimize white flash on load when user has dark mode enabled -->
+ <script>
+ try {
+ function setTheme(theme) {
+ if (theme === "dark") {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ }
+
+ function getTheme() {
+ const theme = window.localStorage.getItem("kj-diagramming-theme");
+
+ if (theme && theme === "system") {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ } else {
+ return theme || "light";
+ }
+ }
+
+ setTheme(getTheme());
+ } catch (e) {
+ console.error("Error setting dark mode", e);
+ }
+ </script>
+ <style>
+ html.dark {
+ background-color: #121212;
+ color: #fff;
+ }
+ </style>
+
+ <!-- Warmup the connection for Google fonts -->
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+
+ <!------------------------------------------------------------------------->
+ <% if (typeof PROD != 'undefined' && PROD == true) { %>
+ <script>
+ // Redirect plus users which have auto-redirect enabled.
+ //
+ // Redirect only the bare root path, so link/room/library urls are not
+ // redirected.
+ //
+ // Putting into index.html for best performance (can't redirect on server
+ // due to location.hash checks).
+ if (
+ window.location.pathname === "/" &&
+ !window.location.hash &&
+ !window.location.search &&
+ // if its present redirect
+ document.cookie.includes("excplus-autoredirect=true")
+ ) {
+ // disabled in this fork
+ }
+ </script>
+
+ <!-- Following placeholder is replaced during the build step -->
+ <!-- PLACEHOLDER:EXCALIDRAW_APP_FONTS -->
+
+ <!-- Register Assistant as the UI font, before the scene inits -->
+ <link
+ rel="stylesheet"
+ href="../packages/excalidraw/fonts/fonts.css"
+ type="text/css"
+ />
+
+ <% } else { %>
+ <script>
+ window.EXCALIDRAW_ASSET_PATH = window.origin;
+ </script>
+ <% } %>
+
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+
+ <!-- kj-diagramming version -->
+ <meta name="version" content="{version}" />
+
+ <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' &&
+ VITE_APP_DEV_DISABLE_LIVE_RELOAD == true) { %>
+ <script>
+ {
+ const _WebSocket = window.WebSocket;
+ window.WebSocket = function (url) {
+ if (/ws:\/\/localhost:.+?\/sockjs-node/.test(url)) {
+ console.info(
+ "[!!!] Live reload is disabled via VITE_APP_DEV_DISABLE_LIVE_RELOAD [!!!]",
+ );
+ } else {
+ return new _WebSocket(url);
+ }
+ };
+ }
+ </script>
+ <% } %>
+ <script>
+ // setting this so that libraries installation reuses this window tab.
+ window.name = "_kj-diagramming";
+ </script>
+
+ <!-- FIXME: remove this when we update CRA (fix SW caching) -->
+ <style>
+ body,
+ html {
+ margin: 0;
+ -webkit-text-size-adjust: 100%;
+
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ .visually-hidden {
+ position: absolute !important;
+ height: 1px;
+ width: 1px;
+ overflow: hidden;
+ clip: rect(1px, 1px, 1px, 1px);
+ white-space: nowrap;
+ user-select: none;
+ }
+
+ #root {
+ height: 100%;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ }
+
+ @media screen and (min-width: 1200px) {
+ #root {
+ -webkit-touch-callout: default;
+ -webkit-user-select: auto;
+ -khtml-user-select: auto;
+ -moz-user-select: auto;
+ -ms-user-select: auto;
+ user-select: auto;
+ }
+ }
+ </style>
+ </head>
+
+ <body>
+ <noscript> You need to enable JavaScript to run this app. </noscript>
+ <header>
+ <h1 class="visually-hidden">kj-diagramming</h1>
+ </header>
+ <div id="root"></div>
+ <script type="module" src="index.tsx"></script>
+ <% if (typeof PROD != 'undefined' && PROD == true) { %>
+ <!-- 100% privacy friendly analytics -->
+ <script>
+ // need to load this script dynamically bcs. of iframe embed tracking
+ var scriptEle = document.createElement("script");
+ scriptEle.setAttribute(
+ "src",
+ "https://scripts.simpleanalyticscdn.com/latest.js",
+ );
+ scriptEle.setAttribute("type", "text/javascript");
+ scriptEle.setAttribute("defer", true);
+ scriptEle.setAttribute("async", true);
+ // if iframe
+ if (window.self !== window.top) {
+ scriptEle.setAttribute("data-auto-collect", true);
+ }
+
+ document.body.appendChild(scriptEle);
+
+ // if iframe
+ if (window.self !== window.top) {
+ scriptEle.addEventListener("load", () => {
+ if (window.sa_pageview) {
+ window.window.sa_event(action, {
+ category: "iframe",
+ label: "embed",
+ value: window.location.pathname,
+ });
+ }
+ });
+ }
+ </script>
+ <!-- end LEGACY GOOGLE ANALYTICS -->
+ <% } %>
+ </body>
+</html>
diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss
new file mode 100644
index 0000000..cfaaf9c
--- /dev/null
+++ b/excalidraw-app/index.scss
@@ -0,0 +1,117 @@
+.excalidraw {
+ --color-primary-contrast-offset: #625ee0; // to offset Chubb illusion
+
+ &.theme--dark {
+ --color-primary-contrast-offset: #726dff; // to offset Chubb illusion
+ }
+
+ .top-right-ui {
+ display: flex;
+ justify-content: center;
+ align-items: flex-start;
+ }
+
+ .footer-center {
+ justify-content: flex-end;
+ margin-top: auto;
+ margin-bottom: auto;
+ margin-inline-start: auto;
+ }
+
+ .encrypted-icon {
+ border-radius: var(--space-factor);
+ color: var(--color-primary);
+ margin-top: auto;
+ margin-bottom: auto;
+ margin-inline-start: auto;
+ margin-inline-end: 0.6em;
+ z-index: var(--zIndex-layerUI);
+
+ svg {
+ width: 1.2rem;
+ height: 1.2rem;
+ }
+ }
+
+ .dropdown-menu-container {
+ .dropdown-menu-item {
+ &.active-collab {
+ background-color: #ecfdf5;
+ color: #064e3c;
+ }
+ &.highlighted {
+ color: var(--color-promo);
+ font-weight: 700;
+ .dropdown-menu-item__icon g {
+ stroke-width: 2;
+ }
+ }
+ }
+ }
+
+ &.theme--dark {
+ .dropdown-menu-item {
+ &.active-collab {
+ background-color: #064e3c;
+ color: #ecfdf5;
+ }
+ }
+ }
+
+ .collab-offline-warning {
+ pointer-events: none;
+ position: absolute;
+ top: 6.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ padding: 0.5rem 1rem;
+ font-size: 0.875rem;
+ text-align: center;
+ line-height: 1.5;
+ border-radius: var(--border-radius-md);
+ background-color: var(--color-warning);
+ color: var(--color-text-warning);
+ z-index: 6;
+ white-space: pre;
+ }
+}
+
+.excalidraw-app.is-collaborating {
+ [data-testid="clear-canvas-button"] {
+ display: none;
+ }
+}
+
+.plus-button {
+ display: flex;
+ justify-content: center;
+ cursor: pointer;
+ align-items: center;
+ border: 1px solid var(--color-primary);
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--border-radius-lg);
+ background-color: var(--island-bg-color);
+ color: var(--color-primary) !important;
+ text-decoration: none !important;
+
+ font-size: 0.75rem;
+ box-sizing: border-box;
+ height: var(--lg-button-size);
+
+ &:hover {
+ background-color: var(--color-primary);
+ color: white !important;
+ }
+
+ &:active {
+ background-color: var(--color-primary-darker);
+ }
+}
+
+.theme--dark {
+ .plus-button {
+ &:hover {
+ color: black !important;
+ }
+ }
+}
diff --git a/excalidraw-app/index.tsx b/excalidraw-app/index.tsx
new file mode 100644
index 0000000..a28bd9e
--- /dev/null
+++ b/excalidraw-app/index.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import ExcalidrawApp from "./App";
+import { registerSW } from "virtual:pwa-register";
+
+import "../excalidraw-app/sentry";
+window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
+const rootElement = document.getElementById("root")!;
+const root = createRoot(rootElement);
+registerSW();
+root.render(
+ <StrictMode>
+ <ExcalidrawApp />
+ </StrictMode>,
+);
diff --git a/excalidraw-app/package.json b/excalidraw-app/package.json
new file mode 100644
index 0000000..e5dcde0
--- /dev/null
+++ b/excalidraw-app/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "excalidraw-app",
+ "version": "1.0.0",
+ "private": true,
+ "homepage": ".",
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all",
+ "not safari < 12",
+ "not kaios <= 2.5",
+ "not edge < 79",
+ "not chrome < 70",
+ "not and_uc < 13",
+ "not samsung < 10"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "engines": {
+ "node": "18.0.0 - 22.x.x"
+ },
+ "dependencies": {
+ "@excalidraw/random-username": "1.0.0",
+ "@sentry/browser": "9.0.1",
+ "callsites": "4.2.0",
+ "firebase": "11.3.1",
+ "i18next-browser-languagedetector": "6.1.4",
+ "idb-keyval": "6.0.3",
+ "jotai": "2.11.0",
+ "react": "19.0.0",
+ "react-dom": "19.0.0",
+ "socket.io-client": "4.7.2",
+ "vite-plugin-html": "3.2.2"
+ },
+ "prettier": "@excalidraw/prettier-config",
+ "scripts": {
+ "build-node": "node ./scripts/build-node.js",
+ "build:app:docker": "cross-env VITE_APP_DISABLE_SENTRY=true vite build",
+ "build:app": "cross-env VITE_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA cross-env VITE_APP_ENABLE_TRACKING=true vite build",
+ "build:version": "node ../scripts/build-version.js",
+ "build": "yarn build:app && yarn build:version",
+ "start": "yarn && vite",
+ "start:production": "yarn build && yarn serve",
+ "serve": "npx http-server build -a localhost -p 5001 -o",
+ "build:preview": "yarn build && vite preview --port 5000"
+ },
+ "devDependencies": {
+ "vite-plugin-sitemap": "0.7.1"
+ }
+}
diff --git a/excalidraw-app/sentry.ts b/excalidraw-app/sentry.ts
new file mode 100644
index 0000000..30b84f3
--- /dev/null
+++ b/excalidraw-app/sentry.ts
@@ -0,0 +1,81 @@
+import * as Sentry from "@sentry/browser";
+import callsites from "callsites";
+
+const SentryEnvHostnameMap: { [key: string]: string } = {
+ "excalidraw.com": "production",
+ "staging.excalidraw.com": "staging",
+ "vercel.app": "staging",
+};
+
+const SENTRY_DISABLED = import.meta.env.VITE_APP_DISABLE_SENTRY === "true";
+
+// Disable Sentry locally or inside the Docker to avoid noise/respect privacy
+const onlineEnv =
+ !SENTRY_DISABLED &&
+ Object.keys(SentryEnvHostnameMap).find(
+ (item) => window.location.hostname.indexOf(item) >= 0,
+ );
+
+Sentry.init({
+ dsn: onlineEnv
+ ? "https://7bfc596a5bf945eda6b660d3015a5460@sentry.io/5179260"
+ : undefined,
+ environment: onlineEnv ? SentryEnvHostnameMap[onlineEnv] : undefined,
+ release: import.meta.env.VITE_APP_GIT_SHA,
+ ignoreErrors: [
+ "undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
+ "InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.", // Not much we can do about the IndexedDB closing error
+ /(Failed to fetch|(fetch|loading) dynamically imported module)/i, // This is happening when a service worker tries to load an old asset
+ /QuotaExceededError: (The quota has been exceeded|.*setItem.*Storage)/i, // localStorage quota exceeded
+ "Internal error opening backing store for indexedDB.open", // Private mode and disabled indexedDB
+ ],
+ integrations: [
+ Sentry.captureConsoleIntegration({
+ levels: ["error"],
+ }),
+ ],
+ beforeSend(event) {
+ if (event.request?.url) {
+ event.request.url = event.request.url.replace(/#.*$/, "");
+ }
+
+ if (!event.exception) {
+ event.exception = {
+ values: [
+ {
+ type: "ConsoleError",
+ value: event.message ?? "Unknown error",
+ stacktrace: {
+ frames: callsites()
+ .slice(1)
+ .filter(
+ (frame) =>
+ frame.getFileName() &&
+ !frame.getFileName()?.includes("@sentry_browser.js"),
+ )
+ .map((frame) => ({
+ filename: frame.getFileName() ?? undefined,
+ function: frame.getFunctionName() ?? undefined,
+ in_app: !(
+ frame.getFileName()?.includes("node_modules") ?? false
+ ),
+ lineno: frame.getLineNumber() ?? undefined,
+ colno: frame.getColumnNumber() ?? undefined,
+ })),
+ },
+ mechanism: {
+ type: "instrument",
+ handled: true,
+ data: {
+ function: "console.error",
+ handler: "Sentry.beforeSend",
+ },
+ },
+ },
+ ],
+ };
+ }
+
+ return event;
+ },
+});
diff --git a/excalidraw-app/share/ShareDialog.scss b/excalidraw-app/share/ShareDialog.scss
new file mode 100644
index 0000000..436f411
--- /dev/null
+++ b/excalidraw-app/share/ShareDialog.scss
@@ -0,0 +1,166 @@
+@import "../../packages/excalidraw/css/variables.module.scss";
+
+.excalidraw {
+ .ShareDialog {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+
+ @include isMobile {
+ height: calc(100vh - 5rem);
+ }
+
+ &__separator {
+ border-top: 1px solid var(--dialog-border-color);
+ text-align: center;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-top: 1em;
+
+ span {
+ background: var(--island-bg-color);
+ padding: 0px 0.75rem;
+ transform: translateY(-1ch);
+ display: inline-flex;
+ line-height: 1;
+ }
+ }
+
+ &__popover {
+ @keyframes ShareDialog__popover__scaleIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+
+ box-sizing: border-box;
+ z-index: 100;
+
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 0.125rem 0.5rem;
+ gap: 0.125rem;
+
+ height: 1.125rem;
+
+ border: none;
+ border-radius: 0.6875rem;
+
+ font-family: "Assistant";
+ font-style: normal;
+ font-weight: 600;
+ font-size: 0.75rem;
+ line-height: 110%;
+
+ background: var(--color-success);
+ color: var(--color-success-text);
+
+ & > svg {
+ width: 0.875rem;
+ height: 0.875rem;
+ }
+
+ transform-origin: var(--radix-popover-content-transform-origin);
+ animation: ShareDialog__popover__scaleIn 150ms ease-out;
+ }
+
+ &__picker {
+ font-family: "Assistant";
+
+ &__illustration {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+
+ & svg {
+ filter: var(--theme-filter);
+ }
+ }
+
+ &__header {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+
+ font-weight: 700;
+ font-size: 1.3125rem;
+ line-height: 130%;
+
+ color: var(--color-primary);
+ }
+
+ &__description {
+ font-weight: 400;
+ font-size: 0.875rem;
+ line-height: 150%;
+
+ text-align: center;
+
+ color: var(--text-primary-color);
+
+ & strong {
+ display: block;
+ font-weight: 700;
+ }
+ }
+
+ &__button {
+ display: flex;
+
+ align-items: center;
+ justify-content: center;
+ }
+ }
+
+ &__active {
+ &__share {
+ display: none !important;
+
+ @include isMobile {
+ display: flex !important;
+ }
+ }
+
+ &__header {
+ margin: 0;
+ }
+
+ &__linkRow {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-end;
+ gap: 0.75rem;
+ }
+
+ &__description {
+ border-top: 1px solid var(--color-gray-20);
+
+ padding: 0.5rem 0.5rem 0;
+ font-weight: 400;
+ font-size: 0.75rem;
+ line-height: 150%;
+
+ & p {
+ margin: 0;
+ }
+
+ & p + p {
+ margin-top: 1em;
+ }
+ }
+
+ &__actions {
+ display: flex;
+ justify-content: center;
+ }
+ }
+ }
+}
diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx
new file mode 100644
index 0000000..bba7e36
--- /dev/null
+++ b/excalidraw-app/share/ShareDialog.tsx
@@ -0,0 +1,290 @@
+import { useEffect, useRef, useState } from "react";
+import { copyTextToSystemClipboard } from "@excalidraw/excalidraw/clipboard";
+import { trackEvent } from "@excalidraw/excalidraw/analytics";
+import { getFrame } from "@excalidraw/excalidraw/utils";
+import { useI18n } from "@excalidraw/excalidraw/i18n";
+import { KEYS } from "@excalidraw/excalidraw/keys";
+import { Dialog } from "@excalidraw/excalidraw/components/Dialog";
+import {
+ copyIcon,
+ LinkIcon,
+ playerPlayIcon,
+ playerStopFilledIcon,
+ share,
+ shareIOS,
+ shareWindows,
+} from "@excalidraw/excalidraw/components/icons";
+import { TextField } from "@excalidraw/excalidraw/components/TextField";
+import { FilledButton } from "@excalidraw/excalidraw/components/FilledButton";
+import type { CollabAPI } from "../collab/Collab";
+import { activeRoomLinkAtom } from "../collab/Collab";
+import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
+import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
+import { atom, useAtom, useAtomValue } from "../app-jotai";
+
+import "./ShareDialog.scss";
+
+type OnExportToBackend = () => void;
+type ShareDialogType = "share" | "collaborationOnly";
+
+export const shareDialogStateAtom = atom<
+ { isOpen: false } | { isOpen: true; type: ShareDialogType }
+>({ isOpen: false });
+
+const getShareIcon = () => {
+ const navigator = window.navigator as any;
+ const isAppleBrowser = /Apple/.test(navigator.vendor);
+ const isWindowsBrowser = navigator.appVersion.indexOf("Win") !== -1;
+
+ if (isAppleBrowser) {
+ return shareIOS;
+ } else if (isWindowsBrowser) {
+ return shareWindows;
+ }
+
+ return share;
+};
+
+export type ShareDialogProps = {
+ collabAPI: CollabAPI | null;
+ handleClose: () => void;
+ onExportToBackend: OnExportToBackend;
+ type: ShareDialogType;
+};
+
+const ActiveRoomDialog = ({
+ collabAPI,
+ activeRoomLink,
+ handleClose,
+}: {
+ collabAPI: CollabAPI;
+ activeRoomLink: string;
+ handleClose: () => void;
+}) => {
+ const { t } = useI18n();
+ const [, setJustCopied] = useState(false);
+ const timerRef = useRef<number>(0);
+ const ref = useRef<HTMLInputElement>(null);
+ const isShareSupported = "share" in navigator;
+ const { onCopy, copyStatus } = useCopyStatus();
+
+ const copyRoomLink = async () => {
+ try {
+ await copyTextToSystemClipboard(activeRoomLink);
+ } catch (e) {
+ collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
+ }
+
+ setJustCopied(true);
+
+ if (timerRef.current) {
+ window.clearTimeout(timerRef.current);
+ }
+
+ timerRef.current = window.setTimeout(() => {
+ setJustCopied(false);
+ }, 3000);
+
+ ref.current?.select();
+ };
+
+ const shareRoomLink = async () => {
+ try {
+ await navigator.share({
+ title: t("roomDialog.shareTitle"),
+ text: t("roomDialog.shareTitle"),
+ url: activeRoomLink,
+ });
+ } catch (error: any) {
+ // Just ignore.
+ }
+ };
+
+ return (
+ <>
+ <h3 className="ShareDialog__active__header">
+ {t("labels.liveCollaboration").replace(/\./g, "")}
+ </h3>
+ <TextField
+ defaultValue={collabAPI.getUsername()}
+ placeholder="Your name"
+ label="Your name"
+ onChange={collabAPI.setUsername}
+ onKeyDown={(event) => event.key === KEYS.ENTER && handleClose()}
+ />
+ <div className="ShareDialog__active__linkRow">
+ <TextField
+ ref={ref}
+ label="Link"
+ readonly
+ fullWidth
+ value={activeRoomLink}
+ />
+ {isShareSupported && (
+ <FilledButton
+ size="large"
+ variant="icon"
+ label="Share"
+ icon={getShareIcon()}
+ className="ShareDialog__active__share"
+ onClick={shareRoomLink}
+ />
+ )}
+ <FilledButton
+ size="large"
+ label={t("buttons.copyLink")}
+ icon={copyIcon}
+ status={copyStatus}
+ onClick={() => {
+ copyRoomLink();
+ onCopy();
+ }}
+ />
+ </div>
+ <div className="ShareDialog__active__description">
+ <p>
+ <span
+ role="img"
+ aria-hidden="true"
+ className="ShareDialog__active__description__emoji"
+ >
+ 🔒{" "}
+ </span>
+ {t("roomDialog.desc_privacy")}
+ </p>
+ <p>{t("roomDialog.desc_exitSession")}</p>
+ </div>
+
+ <div className="ShareDialog__active__actions">
+ <FilledButton
+ size="large"
+ variant="outlined"
+ color="danger"
+ label={t("roomDialog.button_stopSession")}
+ icon={playerStopFilledIcon}
+ onClick={() => {
+ trackEvent("share", "room closed");
+ collabAPI.stopCollaboration();
+ if (!collabAPI.isCollaborating()) {
+ handleClose();
+ }
+ }}
+ />
+ </div>
+ </>
+ );
+};
+
+const ShareDialogPicker = (props: ShareDialogProps) => {
+ const { t } = useI18n();
+
+ const { collabAPI } = props;
+
+ const startCollabJSX = collabAPI ? (
+ <>
+ <div className="ShareDialog__picker__header">
+ {t("labels.liveCollaboration").replace(/\./g, "")}
+ </div>
+
+ <div className="ShareDialog__picker__description">
+ <div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
+ {t("roomDialog.desc_privacy")}
+ </div>
+
+ <div className="ShareDialog__picker__button">
+ <FilledButton
+ size="large"
+ label={t("roomDialog.button_startSession")}
+ icon={playerPlayIcon}
+ onClick={() => {
+ trackEvent("share", "room creation", `ui (${getFrame()})`);
+ collabAPI.startCollaboration(null);
+ }}
+ />
+ </div>
+
+ {props.type === "share" && (
+ <div className="ShareDialog__separator">
+ <span>{t("shareDialog.or")}</span>
+ </div>
+ )}
+ </>
+ ) : null;
+
+ return (
+ <>
+ {startCollabJSX}
+
+ {props.type === "share" && (
+ <>
+ <div className="ShareDialog__picker__header">
+ {t("exportDialog.link_title")}
+ </div>
+ <div className="ShareDialog__picker__description">
+ {t("exportDialog.link_details")}
+ </div>
+
+ <div className="ShareDialog__picker__button">
+ <FilledButton
+ size="large"
+ label={t("exportDialog.link_button")}
+ icon={LinkIcon}
+ onClick={async () => {
+ await props.onExportToBackend();
+ props.handleClose();
+ }}
+ />
+ </div>
+ </>
+ )}
+ </>
+ );
+};
+
+const ShareDialogInner = (props: ShareDialogProps) => {
+ const activeRoomLink = useAtomValue(activeRoomLinkAtom);
+
+ return (
+ <Dialog size="small" onCloseRequest={props.handleClose} title={false}>
+ <div className="ShareDialog">
+ {props.collabAPI && activeRoomLink ? (
+ <ActiveRoomDialog
+ collabAPI={props.collabAPI}
+ activeRoomLink={activeRoomLink}
+ handleClose={props.handleClose}
+ />
+ ) : (
+ <ShareDialogPicker {...props} />
+ )}
+ </div>
+ </Dialog>
+ );
+};
+
+export const ShareDialog = (props: {
+ collabAPI: CollabAPI | null;
+ onExportToBackend: OnExportToBackend;
+}) => {
+ const [shareDialogState, setShareDialogState] = useAtom(shareDialogStateAtom);
+
+ const { openDialog } = useUIAppState();
+
+ useEffect(() => {
+ if (openDialog) {
+ setShareDialogState({ isOpen: false });
+ }
+ }, [openDialog, setShareDialogState]);
+
+ if (!shareDialogState.isOpen) {
+ return null;
+ }
+
+ return (
+ <ShareDialogInner
+ handleClose={() => setShareDialogState({ isOpen: false })}
+ collabAPI={props.collabAPI}
+ onExportToBackend={props.onExportToBackend}
+ type={shareDialogState.type}
+ />
+ );
+};
diff --git a/excalidraw-app/tests/LanguageList.test.tsx b/excalidraw-app/tests/LanguageList.test.tsx
new file mode 100644
index 0000000..a0cfe07
--- /dev/null
+++ b/excalidraw-app/tests/LanguageList.test.tsx
@@ -0,0 +1,34 @@
+import { defaultLang } from "@excalidraw/excalidraw/i18n";
+import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
+import {
+ screen,
+ fireEvent,
+ waitFor,
+ render,
+} from "@excalidraw/excalidraw/tests/test-utils";
+
+import ExcalidrawApp from "../App";
+
+describe("Test LanguageList", () => {
+ it("rerenders UI on language change", async () => {
+ await render(<ExcalidrawApp />);
+
+ // select rectangle tool to show properties menu
+ UI.clickTool("rectangle");
+ // english lang should display `thin` label
+ expect(screen.queryByTitle(/thin/i)).not.toBeNull();
+ fireEvent.click(document.querySelector(".dropdown-menu-button")!);
+
+ fireEvent.change(document.querySelector(".dropdown-select__language")!, {
+ target: { value: "de-DE" },
+ });
+ // switching to german, `thin` label should no longer exist
+ await waitFor(() => expect(screen.queryByTitle(/thin/i)).toBeNull());
+ // reset language
+ fireEvent.change(document.querySelector(".dropdown-select__language")!, {
+ target: { value: defaultLang.code },
+ });
+ // switching back to English
+ await waitFor(() => expect(screen.queryByTitle(/thin/i)).not.toBeNull());
+ });
+});
diff --git a/excalidraw-app/tests/MobileMenu.test.tsx b/excalidraw-app/tests/MobileMenu.test.tsx
new file mode 100644
index 0000000..a6eef3b
--- /dev/null
+++ b/excalidraw-app/tests/MobileMenu.test.tsx
@@ -0,0 +1,51 @@
+import ExcalidrawApp from "../App";
+import {
+ mockBoundingClientRect,
+ render,
+ restoreOriginalGetBoundingClientRect,
+} from "@excalidraw/excalidraw/tests/test-utils";
+
+import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
+
+describe("Test MobileMenu", () => {
+ const { h } = window;
+ const dimensions = { height: 400, width: 800 };
+
+ beforeAll(() => {
+ mockBoundingClientRect(dimensions);
+ });
+
+ beforeEach(async () => {
+ await render(<ExcalidrawApp />);
+ // @ts-ignore
+ h.app.refreshViewportBreakpoints();
+ // @ts-ignore
+ h.app.refreshEditorBreakpoints();
+ });
+
+ afterAll(() => {
+ restoreOriginalGetBoundingClientRect();
+ });
+
+ it("should set device correctly", () => {
+ expect(h.app.device).toMatchInlineSnapshot(`
+ {
+ "editor": {
+ "canFitSidebar": false,
+ "isMobile": true,
+ },
+ "isTouchScreen": false,
+ "viewport": {
+ "isLandscape": false,
+ "isMobile": true,
+ },
+ }
+ `);
+ });
+
+ it("should initialize with welcome screen and hide once user interacts", async () => {
+ expect(document.querySelector(".welcome-screen-center")).toMatchSnapshot();
+ UI.clickTool("rectangle");
+ expect(document.querySelector(".welcome-screen-center")).toBeNull();
+ });
+});
diff --git a/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
new file mode 100644
index 0000000..77fc147
--- /dev/null
+++ b/excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap
@@ -0,0 +1,247 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Test MobileMenu > should initialize with welcome screen and hide once user interacts 1`] = `
+<div
+ class="welcome-screen-center"
+>
+ <div
+ class="welcome-screen-center__logo excalifont welcome-screen-decor"
+ >
+ <div
+ class="ExcalidrawLogo is-small"
+ >
+ <svg
+ class="ExcalidrawLogo-icon"
+ fill="none"
+ viewBox="0 0 40 40"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M39.9 32.889a.326.326 0 0 0-.279-.056c-2.094-3.083-4.774-6-7.343-8.833l-.419-.472a.212.212 0 0 0-.056-.139.586.586 0 0 0-.167-.111l-.084-.083-.056-.056c-.084-.167-.28-.278-.475-.167-.782.39-1.507.973-2.206 1.528-.92.722-1.842 1.445-2.708 2.25a8.405 8.405 0 0 0-.977 1.028c-.14.194-.028.361.14.444-.615.611-1.23 1.223-1.843 1.861a.315.315 0 0 0-.084.223c0 .083.056.166.111.194l1.09.833v.028c1.535 1.528 4.244 3.611 7.12 5.861.418.334.865.667 1.284 1 .195.223.39.473.558.695.084.11.28.139.391.055.056.056.14.111.196.167a.398.398 0 0 0 .167.056.255.255 0 0 0 .224-.111.394.394 0 0 0 .055-.167c.029 0 .028.028.056.028a.318.318 0 0 0 .224-.084l5.082-5.528a.309.309 0 0 0 0-.444Zm-14.63-1.917a.485.485 0 0 0 .111.14c.586.5 1.2 1 1.843 1.555l-2.569-1.945-.251-.166c-.056-.028-.112-.084-.168-.111l-.195-.167.056-.056.055-.055.112-.111c.866-.861 2.346-2.306 3.1-3.028-.81.805-2.43 3.167-2.095 3.944Zm8.767 6.89-2.122-1.612a44.713 44.713 0 0 0-2.625-2.5c1.145.861 2.122 1.611 2.262 1.75 1.117.972 1.06.806 1.815 1.445l.921.666a1.06 1.06 0 0 1-.251.25Zm.558.416-.056-.028c.084-.055.168-.111.252-.194l-.196.222ZM1.089 5.75c.055.361.14.722.195 1.056.335 1.833.67 3.5 1.284 4.75l.252.944c.084.361.223.806.363.917 1.424 1.25 3.602 3.11 5.947 4.889a.295.295 0 0 0 .363 0s0 .027.028.027a.254.254 0 0 0 .196.084.318.318 0 0 0 .223-.084c2.988-3.305 5.221-6.027 6.813-8.305.112-.111.14-.278.14-.417.111-.111.195-.25.307-.333.111-.111.111-.306 0-.39l-.028-.027c0-.055-.028-.139-.084-.167-.698-.666-1.2-1.138-1.731-1.638-.922-.862-1.871-1.75-3.881-3.75l-.028-.028c-.028-.028-.056-.056-.112-.056-.558-.194-1.703-.389-3.127-.639C6.087 2.223 3.21 1.723.614.944c0 0-.168 0-.196.028l-.083.084c-.028.027-.056.055-.224.11h.056-.056c.028.167.028.278.084.473 0 .055.112.5.112.555l.782 3.556Zm15.496 3.278-.335-.334c.084.112.196.195.335.334Zm-3.546 4.666-.056.056c0-.028.028-.056.056-.056Zm-2.038-10c.168.167.866.834 1.033.973-.726-.334-2.54-1.167-3.379-1.445.838.167 1.983.334 2.346.472ZM1.424 2.306c.419.722.754 3.222 1.089 5.666-.196-.778-.335-1.555-.503-2.278-.251-1.277-.503-2.416-.838-3.416.056 0 .14 0 .252.028Zm-.168-.584c-.112 0-.223-.028-.307-.028 0-.027 0-.055-.028-.055.14 0 .223.028.335.083Zm-1.089.222c0-.027 0-.027 0 0ZM39.453 1.333c.028-.11-.558-.61-.363-.639.42-.027.42-.666 0-.666-.558.028-1.144.166-1.675.25-.977.194-1.982.389-2.96.61-2.205.473-4.383.973-6.561 1.557-.67.194-1.424.333-2.066.666-.224.111-.196.333-.084.472-.056.028-.084.028-.14.056-.195.028-.363.056-.558.083-.168.028-.252.167-.224.334 0 .027.028.083.028.11-1.173 1.556-2.485 3.195-3.909 4.945-1.396 1.611-2.876 3.306-4.356 5.056-4.719 5.5-10.052 11.75-15.943 17.25a.268.268 0 0 0 0 .389c.028.027.056.055.084.055-.084.084-.168.14-.252.222-.056.056-.084.111-.084.167a.605.605 0 0 0-.111.139c-.112.111-.112.305.028.389.111.11.307.11.39-.028.029-.028.029-.056.056-.056a.44.44 0 0 1 .615 0c.335.362.67.723.977 1.028l-.698-.583c-.112-.111-.307-.083-.39.028-.113.11-.085.305.027.389l7.427 6.194c.056.056.112.056.196.056s.14-.028.195-.084l.168-.166c.028.027.083.027.111.027.084 0 .14-.027.196-.083 10.052-10.055 18.15-17.639 27.42-24.417.083-.055.111-.166.111-.25.112 0 .196-.083.251-.194 1.704-5.194 2.039-9.806 2.15-12.083v-.028c0-.028.028-.056.028-.083.028-.056.028-.084.028-.084a1.626 1.626 0 0 0-.111-1.028ZM21.472 9.5c.446-.5.893-1.028 1.34-1.5-2.876 3.778-7.65 9.583-14.408 16.5 4.607-5.083 9.242-10.333 13.068-15ZM5.193 35.778h.084-.084Zm3.462 3.194c-.027-.028-.027-.028 0-.028v.028Zm4.16-3.583c.224-.25.448-.472.699-.722 0 0 0 .027.028.027-.252.223-.475.445-.726.695Zm1.146-1.111c.14-.14.279-.334.446-.5l.028-.028c1.648-1.694 3.351-3.389 5.082-5.111l.028-.028c.419-.333.921-.694 1.368-1.028a379.003 379.003 0 0 0-6.952 6.695ZM24.794 6.472c-.921 1.195-1.954 2.778-2.82 4.028-2.736 3.944-11.532 13.583-11.727 13.75a1976.983 1976.983 0 0 1-8.042 7.639l-.167.167c-.14-.167-.14-.417.028-.556C14.49 19.861 22.03 10.167 25.074 5.917c-.084.194-.14.36-.28.555Zm4.83 5.695c-1.116-.64-1.646-1.64-1.34-2.611l.084-.334c.028-.083.084-.194.14-.277.307-.5.754-.917 1.257-1.167.027 0 .055 0 .083-.028-.028-.056-.028-.139-.028-.222.028-.167.14-.278.335-.278.335 0 1.369.306 1.76.639.111.083.223.194.335.305.14.167.363.445.474.667.056.028.112.306.196.445.056.222.111.472.084.694-.028.028 0 .194-.028.194a2.668 2.668 0 0 1-.363 1.028c-.028.028-.028.056-.056.084l-.028.027c-.14.223-.335.417-.53.556-.643.444-1.369.583-2.095.389 0 0-.195-.084-.28-.111Zm8.154-.834a39.098 39.098 0 0 1-.893 3.167c0 .028-.028.083 0 .111-.056 0-.084.028-.14.056-2.206 1.61-4.356 3.305-6.506 5.028 1.843-1.64 3.686-3.306 5.613-4.945.558-.5.949-1.139 1.06-1.861l.28-1.667v-.055c.14-.334.67-.195.586.166Z"
+ fill="currentColor"
+ />
+ </svg>
+ <svg
+ class="ExcalidrawLogo-text"
+ fill="none"
+ viewBox="0 0 450 55"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <path
+ d="M429.27 96.74c2.47-1.39 4.78-3.02 6.83-4.95 1.43-1.35 2.73-2.86 3.81-4.51-.66.9-1.4 1.77-2.23 2.59-2.91 2.84-5.72 5.09-8.42 6.87h.01ZM343.6 69.36c.33 3.13.58 6.27.79 9.4.09 1.37.18 2.75.25 4.12-.12-4.46-.27-8.93-.5-13.39-.11-2.08-.24-4.16-.4-6.24-.06 1.79-.11 3.85-.13 6.11h-.01ZM378.47 98.34c.01-.37.07-1.13.01-6.51-.11 1.9-.22 3.81-.31 5.71-.07 1.42-.22 2.91-.16 4.35.39.03.78.07 1.17.1-.92-.85-.76-2.01-.72-3.66l.01.01ZM344.09 86.12c-.09-2.41-.22-4.83-.39-7.24v12.21c.15-.05.32-.09.47-.14.05-1.61-.03-3.23-.09-4.83h.01ZM440.69 66.79c-.22-.34-.45-.67-.69-.99-3.71-4.87-9.91-7.14-15.65-8.55-1.05-.26-2.12-.49-3.18-.71 2.29.59 4.48 1.26 6.64 2.02 7.19 2.54 10.57 5.41 12.88 8.23ZM305.09 72.46l1.2 3.6c.84 2.53 1.67 5.06 2.46 7.61.24.78.5 1.57.73 2.36.22-.04.44-.08.67-.12a776.9 776.9 0 0 1-5.01-13.57c-.02.04-.03.09-.05.13v-.01ZM345.49 90.25v.31c1.48-.42 3.05-.83 4.66-1.2-1.56.25-3.12.52-4.66.89ZM371.02 90.22c0-.57-.04-1.14-.11-1.71-.06-.02-.12-.04-.19-.05-.21-.05-.43-.08-.65-.11.42.16.74.88.95 1.87ZM398.93 54.23c-.13 0-.27-.01-.4-.02l.03.4c.11-.15.23-.27.37-.38ZM401.57 62.28v-.15c-1.22-.24-2.86-.61-3.23-1.25-.09-.15-.18-.51-.27-.98-.09.37-.2.73-.33 1.09 1.24.56 2.52.98 3.83 1.29ZM421.73 88.68c-2.97 1.65-6.28 3.12-9.69 3.68v.18c4.72-.14 11.63-3.85 16.33-8.38-2.04 1.75-4.33 3.24-6.63 4.53l-.01-.01ZM411.28 80.92c-.05-1.2-.09-2.4-.15-3.6-.21 5.66-.46 11.38-.47 14.51.24-.02.48-.04.71-.07.15-3.61.05-7.23-.09-10.83v-.01Z"
+ transform="translate(-144.023 -51.76)"
+ />
+ <path
+ d="M425.38 67.41c-3.5-1.45-7.19-2.57-14.06-3.62.09 1.97.06 4.88-.03 8.12.03.04.06.09.06.15.19 1.36.28 2.73.37 4.1.25 3.77.39 7.55.41 11.33 0 1.38-.01 2.76-.07 4.13 1.4-.25 2.78-.65 4.12-1.15 4.07-1.5 7.94-3.78 11.28-6.54 2.33-1.92 5.13-4.49 5.88-7.58.63-3.53-2.45-6.68-7.97-8.96l.01.02ZM411.35 92.53v-.06l-.34.03c.11.01.22.03.34.03ZM314.26 64.06c-.23-.59-.47-1.17-.7-1.75.57 1.62 1.11 3.25 1.6 4.9l.15.54 2.35 6.05c.32.82.66 1.64.98 2.46-1.38-4.1-2.83-8.17-4.39-12.2h.01ZM156.82 103.07c-.18.13-.38.23-.58.33 1.32-.03 2.66-.2 3.93-.34.86-.09 1.72-.22 2.58-.33-2.12.1-4.12.17-5.94.34h.01ZM210.14 68.88s.03.04.05.07c.18-.31.39-.64.58-.96-.21.3-.42.6-.64.89h.01ZM201.65 82.8c-.5.77-1.02 1.56-1.49 2.37 1.11-1.55 2.21-3.1 3.2-4.59-.23.23-.49.51-.75.79-.32.47-.65.95-.96 1.43ZM194.03 98.66c-.33-.4-.65-.84-1.05-1.17-.24-.2-.07-.49.17-.56-.23-.26-.42-.5-.63-.75 1.51-2.55 3.93-5.87 6.4-9.28-.17-.08-.29-.28-.2-.49.04-.09.09-.17.13-.26-1.21 1.78-2.42 3.55-3.61 5.33-.87 1.31-1.74 2.64-2.54 4-.29.5-.63 1.04-.87 1.61.81.65 1.63 1.27 2.47 1.88-.09-.11-.18-.21-.27-.32v.01ZM307.79 82.93c-1-3.17-2.05-6.32-3.1-9.48-1.62 4.08-3.69 9.17-6.16 15.19 3.32-1.04 6.77-1.87 10.27-2.5-.32-1.08-.67-2.15-1.01-3.21ZM149.5 80.7c.05-1.71.04-3.43 0-5.14-.1 2.26-.16 4.51-.22 6.77-.02.73-.03 1.46-.04 2.19.14-1.27.2-2.55.24-3.82h.02ZM228.98 98.3c.39 1.25.91 3.03.94 3.91.06-.03.12-.07.17-.1.08-1.29-.55-2.65-1.11-3.81ZM307.72 53.36c.81.5 1.53 1.04 2.07 1.49-.38-.8-.78-1.58-1.21-2.35-.17.03-.34.06-.51.11-.43.12-.86.26-1.29.41.35-.01.53.1.94.34ZM283.69 96.14c3.91-7.25 6.89-13.35 8.88-18.15l1.1-2.66c-1.27 2.64-2.56 5.27-3.83 7.9-1.53 3.15-3.06 6.31-4.58 9.47-.87 1.81-1.76 3.62-2.54 5.47.04.02.07.04.11.07.05.05.1.09.15.14.05-.73.27-1.48.71-2.24ZM289.92 103.23s-.04.01-.05.03c0-.02.04-.03.05-.04.05-.05.11-.1.16-.15l.21-.21c-.55 0-1.5-.27-2.55-.72.4.26.8.51 1.22.74.24.13.48.26.73.37.05.02.1.03.14.05a.27.27 0 0 1 .08-.07h.01ZM269.23 68.49c-.39-.19-.82-.48-1.33-.87-3.06-1.56-6.31-2.78-9.36-2.35-3.5.49-5.7 1.11-7.74 2.44 5.71-2.6 12.82-2.07 18.44.79l-.01-.01ZM177.87 53.69l1.06.03c-.96-.22-2-.25-2.89-.3-4.95-.26-9.99.33-14.86 1.19-2.44.43-4.88.95-7.28 1.59 9.09-1.76 15.69-2.77 23.97-2.51ZM219.85 55.51c-.18.12-.36.27-.56.45-.45.53-.86 1.11-1.26 1.66-1.91 2.61-3.71 5.31-5.57 7.95l-.12.18 8.05-10.11c-.18-.05-.36-.1-.55-.13h.01ZM510.71 54.1c.12-.15.29-.3.53-.45.69-.4 3.72-.63 5.87-.74-.36-.02-.73-.04-1.09-.05-1.84-.03-3.67.09-5.49.35.05.3.12.59.18.88v.01ZM510.76 86.02c1.37-3.07 2.49-6.27 3.57-9.46.55-1.64 1.12-3.3 1.6-4.97-1.59 4.01-3.67 9.14-6.2 15.3.24-.08.5-.14.74-.22.1-.22.19-.44.29-.65ZM566.95 75.76c.11-.02.23.03.31.11-.05-.13-.09-.26-.14-.39-.05.09-.11.18-.17.28ZM511.33 86.41c3.08-.89 6.24-1.62 9.46-2.14-1.51-3.98-2.98-7.96-4.39-11.87-.05.15-.09.31-.14.46-1.02 3.32-2.15 6.61-3.39 9.85-.48 1.25-.98 2.49-1.53 3.7h-.01ZM578.24 74.45c.11-.44.23-.87.35-1.31-.31.7-.64 1.39-.97 2.08.09.21.19.4.28.61.12-.46.23-.92.35-1.38h-.01ZM520.62 53.11c-.09 0-.18-.01-.28-.02.38.34.29 1.08.93 2.53l6.65 17.15c2.2 5.68 4.69 11.36 7.41 16.87l1.06 2.17c-2.95-7.05-5.92-14.08-8.87-21.13-1.58-3.79-3.16-7.59-4.7-11.4-.78-1.92-1.73-3.89-2.25-5.91-.03-.1 0-.19.04-.26h.01ZM578.78 77.87c1.45-5.77 3.07-10.43 3.58-13.36.05-.34.16-.88.31-1.55-.67 1.79-1.37 3.56-2.08 5.33-.12.43-.23.86-.35 1.29-.65 2.43-1.29 4.86-1.9 7.3.14.33.29.65.43 1l.01-.01ZM545.3 94.66c.02-.44.03-.83.05-1.12.02-1.01.05-2.02.11-3.02.03-6.66-.46-14.33-1.46-22.8-.13-.42-.27-1.24-.56-2.89 0-.02 0-.04-.01-.06.62 6.61.95 13.25 1.32 19.87.17 3.08.33 6.16.52 9.23.02.25.03.52.04.78l-.01.01ZM580.77 102.81c.13.2.27.38.37.49.27-.11.53-.22.8-.32-.43.09-.82.05-1.17-.16v-.01ZM530.48 104.07h.33c-.36-.13-.71-.32-1.04-.56.14.24.3.47.45.7.06-.08.14-.13.26-.13v-.01ZM542.63 58.82c.06.23.11.47.15.71.14-.33.36-.62.7-.86-.28.05-.57.11-.85.15ZM583.81 57.87c.15-.7.29-1.41.42-2.11-.14.45-.28.9-.42 1.34-.46 1.44-.89 2.89-1.31 4.34.44-1.19.88-2.37 1.31-3.57ZM523.62 91.48c-4.66 1.17-9.05 2.89-14.02 5.27 4.65-1.84 9.48-3.29 14.28-4.63-.09-.22-.17-.41-.26-.64ZM460.64 78.3c-.04-2.9-.11-5.81-.28-8.71-.1-1.68-.17-3.43-.5-5.09-.07.02-.14.03-.2.05.3 6.54.45 12.17.51 17.12.17-.07.34-.14.51-.2 0-1.06-.01-2.11-.03-3.17h-.01ZM470.63 63.24c-3.38-.26-6.81.32-10.1 1.1.41 2.01.47 4.14.57 6.18.18 3.55.25 7.11.27 10.67 3.31-1.38 6.5-3.12 9.3-5.35 1.96-1.56 3.86-3.41 5.02-5.66.73-1.41 1.19-3.22.26-4.65-1.09-1.7-3.46-2.14-5.32-2.29ZM460.29 63.68c1-.24 2.01-.46 3.04-.65-1.15.16-2.37.38-3.71.69v.13c.07-.02.15-.04.22-.05.11-.13.3-.18.45-.11v-.01ZM457.24 100.96c.43-.03.86-.07 1.29-.11.14-.49.27-.99.38-1.49-.44.7-1 1.23-1.67 1.6ZM482.88 104.98c-.18.23-.36.38-.55.47.14.09.27.19.4.28a70.76 70.76 0 0 0 4.37-4.63c.76-.89 1.52-1.81 2.19-2.77-.3-.27-.61-.53-.92-.79-.07 1.94-4.62 6.32-5.49 7.45v-.01Z"
+ transform="translate(-144.023 -51.76)"
+ />
+ <path
+ d="M474.36 63.31c-.4-.16-.84-.27-1.29-.37 1.56.42 3.08 1.22 3.76 2.74.62 1.4.32 2.95-.28 4.32.7-1.22.94-2.34.74-3.47-.24-1.33-1.19-2.54-2.93-3.21v-.01ZM477.34 89.18c-1.2-.81-2.4-1.62-3.6-2.42-.14.1-.26.19-.4.29 1.4.67 2.73 1.39 4 2.13ZM465.88 93.85c.37.25.74.5 1.1.75.46.32.92.65 1.38.97-1.57-1.2-2.01-1.61-2.49-1.72h.01ZM574.92 90.06c-2.28-5.21-4.93-11.13-5.67-12.26-.1-.15-1.57-3.01-1.63-3.08 0 0-.01.02-.02.02.4 1.37 1.09 2.69 1.65 3.99 2.14 4.95 4.36 9.86 6.67 14.73.6 1.26 1.21 2.52 1.83 3.78-.75-2.01-1.64-4.45-2.83-7.18ZM448.73 65.29c.1.2.22.38.34.57.22-.02.43-.06.65-.08v-.08c-.14-.05-.25 0-.99-.41ZM460.16 94.81c-.02.31-.06.59-.1.89-.03 1.71-.33 3.43-.79 5.07.15-.02.3-.03.45-.05.01-.04.02-.08.03-.11.09-.34.15-.69.2-1.03.17-1.07.25-2.16.33-3.24.05-.69.08-1.39.12-2.08-.27.1-.27.26-.24.57v-.02Z"
+ transform="translate(-144.023 -51.76)"
+ />
+ <path
+ d="m328.67 98.12-3.22-6.58c-1.29-2.63-2.53-5.29-3.72-7.97-.25-.85-.52-1.69-.79-2.53-.81-2.57-1.67-5.12-2.55-7.67-1.92-5.53-3.9-11.08-6.32-16.41-.72-1.58-1.46-3.44-2.63-4.79-.03-.17-.16-.29-.34-.36a.282.282 0 0 0-.23-.04c-.06-.01-.12 0-.18.01-.74.06-1.5.38-2.19.61-2.22.77-4.4 1.64-6.63 2.38-.03-.08-.06-.16-.09-.25-.15-.42-.82-.24-.67.19.03.09.07.19.1.28l-.18.06c-.36.11-.28.6 0 .68.18 1.18.63 2.36.98 3.49.03.09.06.17.08.26-.08.23-.17.46-.24.64-.37.98-.79 1.94-1.21 2.9-1.27 2.89-2.62 5.75-3.98 8.6-3.18 6.67-6.44 13.31-9.64 19.97-1.08 2.25-2.2 4.5-3.15 6.81-.13.32.24.5.5.37 1.34 1.33 2.84 2.5 4.4 3.57.65.44 1.31.87 2.01 1.24.4.22.86.48 1.33.5.24.01.35-.19.33-.37.11-.1.21-.21.28-.28.41-.41.81-.84 1.2-1.26.85-.92 1.69-1.87 2.5-2.84 6.31-2.34 12.6-4.31 18.71-5.84 2.14 5.3 3.43 8.43 3.97 9.58.55 1.05 1.15 1.88 1.82 2.52 1.32.56 6.96-.03 9.23-1.96.87-1.28 1.19-2.67.93-4.15-.09-.5-.22-.95-.4-1.33l-.01-.03Zm-20.09-45.61c.43.77.83 1.56 1.21 2.35-.54-.45-1.27-.99-2.07-1.49-.42-.24-.6-.35-.94-.34.43-.15.85-.29 1.29-.41.17-.05.34-.08.51-.11Zm-25.86 45.66c.78-1.85 1.67-3.66 2.54-5.47 1.51-3.16 3.05-6.31 4.58-9.47 1.28-2.63 2.56-5.26 3.83-7.9l-1.1 2.66c-1.99 4.79-4.97 10.9-8.88 18.15-.43.76-.66 1.51-.71 2.24-.05-.05-.1-.09-.15-.14a.259.259 0 0 0-.11-.07Zm6.24 4.71c-.42-.23-.82-.48-1.22-.74 1.05.45 2 .72 2.55.72l-.21.21c-.05.05-.11.1-.16.15-.01.01-.04.03-.05.04 0-.02.03-.02.05-.03a.27.27 0 0 0-.08.07c-.05-.02-.1-.03-.14-.05-.25-.1-.49-.24-.73-.37h-.01Zm15.73-29.43c1.05 3.15 2.1 6.31 3.1 9.48.34 1.06.69 2.13 1.01 3.21-3.5.63-6.95 1.46-10.27 2.5 2.48-6.03 4.54-11.11 6.16-15.19Zm4.79 12.57c-.23-.79-.49-1.58-.73-2.36-.79-2.54-1.63-5.08-2.46-7.61l-1.2-3.6c.02-.04.04-.09.05-.13 1.6 4.45 3.28 9 5.01 13.57l-.67.12v.01Zm5.83-18.27-.15-.54c-.49-1.64-1.03-3.28-1.6-4.9.23.58.47 1.17.7 1.75 1.56 4.03 3.01 8.1 4.39 12.2-.33-.82-.67-1.64-.98-2.46l-2.35-6.05h-.01ZM390.43 79.37c-.13-10.43-.22-17.5-.24-19.97-.24-1.6.21-2.88-.65-3.65-.14-.13-.32-.23-.52-.32h.03c.45 0 .45-.69 0-.7-1.75-.03-3.5-.04-5.25-.14-1.38-.08-2.76-.21-4.15-.31-.07 0-.12.01-.17.04-.21-.07-.47.03-.45.31l.03.45c-.11.14-.19.3-.22.5-.21 1.26-.32 13.67-.36 23.59-.32 5.79-.67 11.57-.97 17.36-.09 1.73-.29 3.54-.21 5.3-.39.02-.38.64.04.69v.12c.05.44.74.45.7 0v-.06c1.1.09 2.2.21 3.3.3 1.14.19 2.44.2 3.29.17 1.73-.05 2.92-.05 3.8-.37.45-.05.9-.11 1.35-.17.44-.06.25-.73-.19-.67h-.01c.24-.32.45-.72.62-1.25.66-1.84.41-6.36.34-11.33l-.13-9.9.02.01Zm-12.26 18.17c.09-1.91.2-3.81.31-5.71.06 5.38 0 6.14-.01 6.51-.05 1.65-.21 2.81.72 3.66-.39-.04-.78-.07-1.17-.1-.06-1.44.09-2.93.16-4.35l-.01-.01ZM588.97 53.85c-2.06-.25-3.17-.51-3.76-.6a.3.3 0 0 1 .04-.08c.22-.39-.39-.75-.6-.35-.56 1.02-.9 2.19-1.26 3.29-.61 1.88-1.17 3.78-1.72 5.68-.63 2.19-1.24 4.39-1.83 6.59-.81 2.03-1.67 4.05-2.61 6.03-1.7-3.64-3.11-6.04-4.03-7.57-2.26-3.74-2.85-5.48-3.57-6.08l.31-.09c.43-.12.25-.8-.19-.67-1.06.3-2.12.6-3.17.95-.93.32-1.85.69-2.76 1.07-.13.05-.19.16-.22.27-.04.02-.08.05-.11.07-.04-.06-.07-.12-.11-.18a.354.354 0 0 0-.48-.12c-.16.09-.22.32-.13.48l.33.54c0 .09.02.18.06.28.51 1.16.78 1.38.72 1.47-2.42 3.44-5.41 7.86-6.2 9.1-1.27 1.97-2.01 3.14-2.45 3.84l-.91-6.56-.43-4.1c-.19-1.85-.37-3.23-.53-4.13-.19-1.1-.3-2.15-.45-3.16-.2-1.36-.29-2.06-.47-2.42h.04c.45.02.45-.68 0-.7-3.43-.16-6.81.94-10.17 1.48-.24-.22-.73-.04-.58.32.24.59.33 1.25.43 1.87.17 1.06.29 2.13.4 3.2.32 3.09.53 6.2.74 9.3.44 6.75.77 13.51 1.17 20.26.11 1.95.13 3.96.46 5.89.05.3.37.31.55.14.74 1.71 2.87 1.27 6.13 1.27 1.34 0 2.39.04 2.99-.11.02.32.48.53.63.18 3.61-8.26 7.41-16.46 12.05-24.2.03-.05.04-.1.05-.15.3.73.64 1.45.94 2.16.97 2.26 1.97 4.52 2.98 6.76 2.26 5.03 4.54 10.07 7.09 14.96.47.9.94 1.79 1.47 2.65.2.32.4.67.66.96-.18.25 0 .68.34.54.91-.38 1.82-.75 2.76-1.07 1.04-.35 2.11-.65 3.17-.95.39-.11.28-.66-.07-.68.62-.4.95-.96.87-1.91-.3-3.34.72-7.47.86-8.52l2.14-11.43c1.75-10.74 3.13-17.51 3.23-20.86.02-.49.08-2.84.13-3.24.17-1.25.48-1-4.96-1.65l.03-.02Zm-46.19 5.67c-.04-.24-.09-.48-.15-.71l.85-.15c-.34.24-.56.53-.7.86Zm1.95 25.12c-.36-6.63-.7-13.26-1.32-19.87 0 .02 0 .04.01.06.29 1.65.44 2.47.56 2.89 1 8.46 1.5 16.14 1.46 22.8-.06.99-.1 2-.11 3.02-.01.29-.03.68-.05 1.12-.01-.26-.03-.53-.04-.78-.19-3.08-.35-6.16-.52-9.23l.01-.01Zm36.4 18.66c-.11-.11-.24-.29-.37-.49.35.21.74.26 1.17.16-.27.11-.53.22-.8.32v.01Zm-.89-33.72c.12-.43.23-.86.35-1.29.71-1.77 1.41-3.55 2.08-5.33-.15.68-.26 1.22-.31 1.55-.5 2.94-2.13 7.59-3.58 13.36-.15-.35-.29-.66-.43-1 .61-2.44 1.25-4.87 1.9-7.3l-.01.01Zm3.56-12.48c.14-.44.28-.89.42-1.34-.13.7-.27 1.41-.42 2.11-.43 1.19-.86 2.38-1.31 3.57.42-1.45.85-2.9 1.31-4.34Zm-5.22 16.05c-.11.44-.23.87-.35 1.31-.12.46-.23.92-.35 1.38-.1-.22-.19-.4-.28-.61.34-.69.66-1.38.97-2.08h.01Zm-11.64 2.62c.06-.1.12-.19.17-.28.05.13.09.26.14.39a.398.398 0 0 0-.31-.11Zm2.3 2.98c-.56-1.3-1.25-2.63-1.65-3.99 0 0 .01-.02.02-.02.06.08 1.52 2.93 1.63 3.08.73 1.13 3.38 7.04 5.67 12.26 1.2 2.73 2.08 5.17 2.83 7.18-.62-1.25-1.23-2.51-1.83-3.78-2.31-4.87-4.53-9.78-6.67-14.73ZM275.92 87.03c-1.06-2.18-1.13-3.45-2.44-2.93-1.52.57-2.94 1.3-4.5 2.1-1.4.72-2.68 1.44-3.92 2.12.01-.25-.24-.5-.51-.34-4.8 2.93-12.41 4.7-17.28 1.31-1.98-1.77-3.32-4.15-3.97-5.78-.29-.95-.49-1.94-.63-2.93-.14-3.34 1.58-6.53 3.9-9.12.8-.79 1.68-1.51 2.66-2.12 3.7-2.3 8.22-3.07 12.51-2.51 2.71.35 5.32 1.24 7.71 2.55.39.22.75-.39.35-.6-.18-.1-.37-.18-.55-.27.56.27 1.03.33 1.51.19l-.48.39c-.15.11-.23.3-.13.48.09.15.33.24.48.13 1.3-.97 2.46-2.09 3.45-3.37.37-.29.64-.6.65-.97v-.02c.08-.33-.03-.7-.21-1.08-.31-.87-.98-2.01-2.19-3.26-2.43-2.52-3.79-3.45-5.68-4.26-1.14-.49-3.12-1.06-4.42-1.23-3.28-.42-10.64-1.21-18.18 4.11-7.74 5.46-11.94 12.3-12.23 20.61-.08 2.06.04 3.98.34 5.71.74 4.18 2.57 8 5.44 11.34 4.26 4.99 9.76 7.52 16.34 7.52 4.85 0 9.69-1.77 14.89-4.62.23-.12.45-.23.68-.35 2.19-1.1 4.37-2.23 6.46-3.5.49-.3 1.03-.61 1.5-.98 1.47-.87 1.11-1.12.49-2.95-.39-1.14-.76-2.7-2.06-5.36l.02-.01Zm-17.38-21.76c3.05-.42 6.31.79 9.36 2.35.51.39.94.68 1.33.87-5.61-2.86-12.72-3.39-18.44-.79 2.05-1.33 4.24-1.95 7.74-2.44l.01.01ZM443.67 72.67c-.4-2.2-1.15-4.33-2.37-6.22-1.49-2.32-3.58-4.19-5.91-5.64-6.17-3.81-13.75-5.11-20.83-6.01-3.23-.41-6.47-.69-9.72-.92l-1.39-.12c-.85-.07-1.52-.1-2.05-.1-1.08-.06-2.17-.12-3.25-.17-.08 0-.14.02-.19.05-.1.05-.18.14-.16.3.27 2.55-.01 5.12-.92 7.52-.15.38.4.56.62.28 1.32.59 2.68 1.05 4.08 1.37 0 2.78-.14 7.58-.33 12.91 0 0 0 .02-.01.03-.61 3.66-.79 7.42-1 11.12-.23 4.01-.43 8.03-.44 12.05 0 .64 0 1.28.03 1.93.02.31 0 .68.15.96.06.11.14.16.24.17-.2.17-.21.54.11.59 3.83.67 7.78.71 11.68.25 2.3-.19 4.87-.65 7.65-1.56 1.85-.54 3.67-1.18 5.43-1.91 7.2-3.02 14.31-8.07 17.35-15.53.76-1.86 1.17-3.8 1.31-5.75.3-1.93.28-3.82-.09-5.58l.01-.02Zm-19.32-15.42c5.74 1.41 11.94 3.68 15.65 8.55.25.32.47.65.69.99-2.3-2.82-5.68-5.69-12.88-8.23-2.16-.76-4.35-1.43-6.64-2.02 1.06.21 2.13.45 3.18.71Zm-25.82-3.04c.13 0 .27.01.4.02-.14.1-.26.23-.37.38 0-.13-.02-.26-.03-.4Zm34.82 22.17c-.75 3.09-3.55 5.66-5.88 7.58-3.35 2.76-7.21 5.03-11.28 6.54-1.33.49-2.71.9-4.12 1.15.06-1.38.08-2.76.07-4.13-.02-3.78-.16-7.56-.41-11.33-.09-1.37-.18-2.74-.37-4.1 0-.06-.03-.11-.06-.15.09-3.25.12-6.16.03-8.12 6.86 1.05 10.56 2.17 14.06 3.62 5.52 2.28 8.59 5.44 7.97 8.96l-.01-.02Zm-22 16.15c-.12 0-.23-.02-.34-.03l.34-.03v.06Zm-.69-.7c0-3.13.26-8.84.47-14.51.06 1.2.11 2.41.15 3.6.15 3.6.25 7.23.09 10.83-.24.03-.48.05-.71.07v.01Zm-12.33-30.94c.37.63 2.01 1.01 3.23 1.25v.15c-1.31-.31-2.59-.73-3.83-1.29.12-.36.23-.72.33-1.09.08.48.18.84.27.98Zm13.7 31.65v-.18c3.41-.56 6.71-2.02 9.69-3.68 2.31-1.28 4.59-2.78 6.63-4.53-4.69 4.53-11.61 8.24-16.33 8.38l.01.01Zm24.07-.75c-2.05 1.93-4.37 3.56-6.83 4.95 2.7-1.78 5.52-4.03 8.42-6.87.82-.82 1.56-1.69 2.23-2.59-1.08 1.65-2.38 3.16-3.81 4.51h-.01ZM187.16 92.14c-.79-2.47-2.1-7.12-3.1-6.87-.19-.01-2.09.77-4.08 1.54-3.06 1.18-5.91 2.13-10.09 2.82-2.74.42-5.87 1.01-10.61 1.06.04-3.34.05-6.01.05-7.99 7.97-.65 12.33-2.11 16.37-3.55 1.11-.39 2.69-1.01 2.63-1.8-.08-.35-.55-1.39-1.17-2.61-.47-1.16-.98-2.31-1.61-3.38-.42-.71-1.04-1.69-1.86-2.06-.11-.08-.22-.13-.29-.12-.02 0-.04 0-.07.01-.19-.04-.39-.05-.6-.01-.17.03-.24.15-.25.28-.04.02-.09.04-.14.05-4.33 1.48-8.85 2.33-13.24 3.61a499.1 499.1 0 0 0-.31-8.19c4.51-.99 8.88-1.38 13.11-1.82 3.68-.38 6.28.12 7.47.34.59.11.9.16 1.16.18h.1c-.1.37.44.66.62.28.02-.04.03-.08.05-.13.15.2.53.22.62-.1.17-.58.19-1.21.21-1.81v-.36c.03-.15.05-.3.07-.45.52-2.47.33-5.09-.64-7.44-.11-.27-.44-.28-.6-.14-.08-.21-.15-.42-.24-.62-.19-.41-.79-.05-.6.35.03.07.05.15.09.22-.98-.42-2.15-.54-3.17-.63-2.17-.19-4.37-.14-6.54 0-5.7.35-11.4 1.3-16.91 2.79-2.08.56-4.13 1.22-6.14 2-4.54 1.05-3.79 1.51-2.17 6.07.18.51.46 1.68.54 1.94.82 2.47 1.08 2.13 3.1 2.13s0 .05 0 .08h.52c-.48 2.66-.51 5.45-.62 8.13-.15 3.48-.22 6.96-.28 10.45 0 .41-.01.82-.02 1.23-.16.29-.33.57-.51.85-.05.38-.09.77-.14 1.18-.42 3.52-.59 6.48-.52 8.8v.34c.02.47.05.76.06.87.16 1.57-.26 3.47 1.35 3.79 1.61.32 3.5.55 4.85.55.11 0 .22-.02.33-.02 1.79.24 3.67.05 5.45-.12 2.85-.28 5.69-.7 8.51-1.19 3.03-.53 6.05-1.14 9.04-1.86 2.4-.58 4.82-1.19 7.13-2.06.51-.19 1.73-.57 2.46-1.14 1.81-.68 2.18-1 1.57-2.67-.23-.62-.48-1.49-.91-2.78l-.03-.02Zm-11.12-38.71c.89.05 1.93.08 2.89.3-.33 0-.68-.02-1.06-.03-8.28-.26-14.88.75-23.97 2.51 2.41-.64 4.85-1.16 7.28-1.59 4.87-.86 9.91-1.45 14.86-1.19Zm-26.53 22.13c.03 1.71.04 3.43 0 5.14-.04 1.27-.11 2.55-.24 3.82 0-.73.02-1.46.04-2.19.05-2.26.12-4.51.22-6.77h-.02Zm6.73 27.85c.2-.1.4-.21.58-.33 1.82-.17 3.82-.24 5.94-.34-.86.11-1.72.24-2.58.33-1.27.14-2.61.31-3.93.34h-.01ZM534.48 85.44c-3.52-8.38-7.07-16.75-10.5-25.17-.63-1.54-1.25-3.09-1.86-4.65-.31-.8-.65-1.6-.87-2.43-.04-.17-.17-.24-.31-.25.1-.2 0-.51-.29-.53-1.59-.08-3.18-.22-4.78-.25-1.96-.03-3.91.13-5.84.42-.31.05-.31.38-.13.56-.03.06-.05.14-.04.22.23 1.54.63 3.06 1.16 4.53.13.35.27.7.41 1.06l-2.68 6.18c-.11.03-.2.09-.25.22-.67 1.9-1.52 3.73-2.34 5.56a536.85 536.85 0 0 1-3.9 8.45c-2.64 5.64-5.34 11.25-7.91 16.93-.44.97-.88 1.94-1.29 2.93-.2.48-.47 1-.55 1.52v.05c-.02.12.02.26.16.34 1.19.73 2.41 1.41 3.66 2.05 1.2.62 2.45 1.25 3.76 1.61.43.12.62-.55.19-.67-1.13-.31-2.2-.83-3.24-1.36 1.09.36 2.1.69 2.75.93 2.82 1.01 2.38 1.1 4.3-3.75 2.1-1.09 4.34-1.96 6.53-2.79 4.35-1.64 8.8-3.03 13.27-4.29.82 2.01 1.77 3.97 2.72 5.92.35.83.62 1.45.79 1.82.22.42.45.8.69 1.15.17.33.33.67.5 1 .42.8.84 1.63 1.4 2.35.23.29.6 0 .55-.31 1.53-.02 3.06-.07 4.58-.27.92-.12 1.82-.32 2.71-.54 1.39-.27 3.85-1.11 3.74-1.42-.67-1.96-1.55-3.87-2.34-5.78-1.57-3.78-3.16-7.56-4.75-11.33v-.01Zm-11.65-26.16c1.54 3.81 3.12 7.6 4.7 11.4 2.94 7.05 5.91 14.09 8.87 21.13l-1.06-2.17c-2.71-5.51-5.2-11.19-7.41-16.87l-6.65-17.15c-.65-1.45-.55-2.19-.93-2.53.09 0 .18.01.28.02a.29.29 0 0 0-.04.26c.52 2.02 1.47 3.98 2.25 5.91h-.01Zm-6.58 13.58c.05-.15.09-.31.14-.46 1.41 3.92 2.88 7.9 4.39 11.87-3.22.52-6.38 1.25-9.46 2.14.55-1.22 1.05-2.46 1.53-3.7 1.24-3.24 2.37-6.53 3.39-9.85h.01Zm-.23-20c.36 0 .73.03 1.09.05-2.15.1-5.18.33-5.87.74-.24.15-.41.3-.53.45-.06-.29-.13-.58-.18-.88 1.82-.26 3.65-.39 5.49-.35v-.01Zm-.09 18.72c-.49 1.67-1.05 3.33-1.6 4.97-1.07 3.19-2.19 6.38-3.57 9.46-.09.21-.19.43-.29.65-.25.07-.5.14-.74.22 2.53-6.16 4.61-11.29 6.2-15.3Zm-6.34 25.16c4.97-2.38 9.37-4.1 14.02-5.27l.26.64c-4.8 1.35-9.63 2.8-14.28 4.63Zm20.17 6.76c.33.23.68.42 1.04.56h-.33c-.12 0-.21.06-.26.13-.15-.23-.31-.45-.45-.7v.01ZM226.57 91.75c-3.55-4.74-6.68-9.11-9.31-12.99 9.2-15.25 10.05-17.81 10.35-18.38.17-.34 1.09-2.27.64-2.53-1.13-.65-1.03-.65-2.97-1.71-1.19-.65-3.04-1.61-4.53-2.12-1.71-.59-1.24-.36-3 2.77-.06.1-.11.2-.17.3-.75 1.02-1.48 2.05-2.2 3.09-1.88 2.71-3.73 5.45-5.69 8.1-3.68-4.91-6.88-8.76-9.51-11.43-.15-.15-.3-.29-.46-.42-1.27-1.28-7.24 3.53-7.93 5.58-.09.09-.19.16-.28.25-.27.26.03.64.33.58.19.65.5 1.29.94 1.91 3.85 5.06 7.19 9.76 9.94 14-1.23 2.61-3.06 5-4.67 7.38l-2.28 3.33c-.5.66-.93 1.23-1.29 1.69-.67.93-2.09 2.61-2.3 3.87-.51.85-1.16 1.84-1.29 2.83-.06.44.61.63.67.19.01-.08.04-.15.06-.22 1.36 1.08 2.76 2.11 4.19 3.11 1.3.91 2.62 1.85 4.04 2.56.21.1.4 0 .48-.17.24.07.48.14.72.2.44.1.62-.57.19-.67-2.02-.48-3.77-1.57-5.23-3.02-.47-.46-.9-.96-1.32-1.46 1.74 1.35 4.2 2.89 5.89 4.14 1.39 1.03 2.85-2.27 4.22-4.2 1.86-2.64 3.96-5.86 5.52-8.29l10.39 14.51c.67.81 1.14 1.21 1.57 1.36-.05.24.12.51.41.4 1.53-.58 3.05-1.19 4.54-1.87 1.52-.69 3.06-1.45 4.36-2.5a.28.28 0 0 0 .12-.23c1.66-1.1.81-1.74-1.41-4.91-1.13-1.58-1.71-2.36-3.7-5.01l-.03-.02Zm2.41 6.54c.56 1.15 1.19 2.52 1.11 3.81-.06.04-.12.07-.17.1-.03-.88-.55-2.66-.94-3.91Zm-16.51-32.73c1.86-2.65 3.65-5.35 5.57-7.95.4-.55.81-1.13 1.26-1.66.19-.18.38-.33.56-.45.18.03.36.08.55.13l-8.05 10.11.12-.18h-.01ZM192.7 95.48c.79-1.37 1.66-2.69 2.54-4 1.19-1.79 2.4-3.56 3.61-5.33-.04.09-.09.17-.13.26-.1.22.03.41.2.49-2.47 3.42-4.89 6.73-6.4 9.28.21.24.4.48.63.75-.24.07-.4.36-.17.56.4.33.72.77 1.05 1.17.09.11.18.21.27.32-.84-.61-1.66-1.24-2.47-1.88.24-.57.58-1.11.87-1.61v-.01Zm7.46-10.32c.47-.81.98-1.59 1.49-2.37.31-.48.64-.95.96-1.43.26-.29.52-.56.75-.79-.99 1.48-2.09 3.03-3.2 4.59Zm10.03-16.22s-.03-.05-.05-.07c.22-.29.43-.59.64-.89-.2.32-.4.65-.58.96h-.01ZM371.54 87.96c-.01-.08-.01-.16-.03-.23-.06-.38-.58-.29-.66.03-.3-.05-.6-.08-.81-.11-1.14-.15-2.29-.19-3.44-.2 1.04-.09 2.09-.18 3.14-.23.45-.02.45-.72 0-.7-6.57.35-13.14 1.23-19.65 2.11-1.53.21-3.05.42-4.57.68-.01 0-.02.01-.04.01-.04-3.33-.13-6.66-.24-9.99-.19-5.7-.4-11.41-.88-17.1-.13-1.51-.23-3.07-.49-4.58 0-.25 0-.48-.02-.68-.06-1.19-.04-2.61-.68-2.78-.16-.07-.72-.16-1.5-.24.22-.17.16-.62-.2-.63-1.19-.04-2.39.09-3.57.23-1.2.14-2.41.32-3.59.6-.16-.1-.41-.06-.5.12-.06.02-.13.03-.19.05-.35.1-.29.55-.03.66-.26.6-.19 2.27-.21 3-.02.66-.66 33.73-.9 40.3-.03.65.06 1.12.04 1.45-.16 3.05.87 4.96 6.34 3.93 1.09-.08 2.75-.77 5.36-1.43 4.13-1.04 5.78-1.52 6.2-1.65 6.43-1.69 6.78-1.97 11.72-2.43.55-.05 4.8-.38 6.03-.3.64.04 1.19.07 1.65.1.09 0 .16-.03.24-.05.1.27.56.33.66-.02.39-1.32.61-2.71.78-4.08.2-1.61.29-3.24.15-4.86.24.03.52-.23.38-.53-.09-.2-.27-.33-.49-.43v-.02Zm-.63.56c.07.57.11 1.14.11 1.71-.21-.99-.53-1.71-.95-1.87.22.03.44.06.65.11.06.01.12.04.19.05Zm-25.41 1.73c1.54-.36 3.1-.64 4.66-.89-1.61.37-3.18.77-4.66 1.2v-.31Zm-.86-7.37c-.07-1.37-.16-2.75-.25-4.12-.21-3.13-.45-6.27-.79-9.4.02-2.25.08-4.31.13-6.11.16 2.08.29 4.16.4 6.24.23 4.46.38 8.93.5 13.39h.01Zm-.94-4c.16 2.41.29 4.83.39 7.24.06 1.6.14 3.22.09 4.83-.15.05-.32.09-.47.14V78.88h-.01ZM483.72 92.83c-3.05-2.28-6.22-4.4-9.38-6.51 8.86-6.49 13.49-12.95 13.73-19.23.04-.76 0-1.5-.13-2.2-.67-3.82-3.5-6.68-8.39-8.48.13.04.27.08.4.13 3.92 1.39 7.74 4.23 8.5 8.56.34 1.95-.05 3.96-.98 5.69-.21.4.39.75.6.35 1.86-3.46 1.46-7.55-.97-10.63-3.53-4.47-9.76-5.88-15.16-6.16-2.32-.12-4.64-.04-6.95.19-6 .32-12.71 1.68-17.63 3.21-.37.11-.67.23-.92.35-.2-.17-.62.02-.57.37v.03c-.64.68-.18 1.64.48 3.21.38.91.67 1.89 1.15 2.58.32.76.68 1.51 1.13 2.19.14.21.38.19.53.07.19-.02.38-.05.57-.08v1.57c-.06.06-.1.13-.11.23-.27 4.18-.34 8.38-.48 12.57l-.3 9.03c-.24 3.91-.44 6.77-.46 7.26-.05.88-.11 1.95.07 2.81-.01.22-.02.43-.04.65 0 .11-.02.23-.03.35 0 .05-.03.27-.01.16-.05.4.5.59.64.28.05.04.12.08.2.08 1.75.13 3.5.28 5.25.3 1.69.02 3.38-.12 5.06-.32.08.23.36.39.55.15.06-.08.11-.17.16-.26.18-.09.24-.32.18-.48.05-.2.1-.4.13-.6.16-.86.25-1.74.33-2.62.11-1.17.17-2.34.23-3.51.15-.01.32-.03.52-.04.36-.03 1.73-.15 2.06-.15.39 0 .7-.02.95-.04 1.76 1.11 3.45 2.35 5.14 3.55 2.83 2.01 5.64 4.04 8.47 6.04 1.42 1 2.85 2 4.29 2.97.1.06.19.07.27.04.08 0 .17-.02.25-.1 1.61-1.56 3.15-3.18 4.6-4.88.75-.88 1.49-1.78 2.15-2.73.01.01.03.02.04.03.34.3.83-.2.49-.49-2.16-1.9-4.34-3.76-6.64-5.48l.03-.01Zm-6.38-3.65a55.72 55.72 0 0 0-4-2.13c.14-.1.26-.19.4-.29 1.2.81 2.4 1.61 3.6 2.42Zm-20.1 11.78c.67-.37 1.23-.91 1.67-1.6-.11.5-.24 1-.38 1.49-.43.04-.86.08-1.29.11Zm2.38-37.24c1.34-.31 2.56-.52 3.71-.69-1.03.19-2.04.41-3.04.65-.14-.07-.34-.02-.45.11-.07.02-.15.04-.22.05v-.13.01Zm.04.84c.07-.02.14-.03.2-.05.34 1.66.41 3.41.5 5.09.17 2.9.24 5.81.28 8.71l.03 3.17c-.17.07-.34.14-.51.2-.06-4.96-.21-10.58-.51-17.12h.01Zm16.04 5.62c-1.16 2.25-3.06 4.1-5.02 5.66-2.8 2.23-5.99 3.97-9.3 5.35-.01-3.56-.09-7.12-.27-10.67-.1-2.04-.16-4.16-.57-6.18 3.3-.78 6.72-1.36 10.1-1.1 1.85.14 4.23.59 5.32 2.29.92 1.43.46 3.24-.26 4.65Zm.85-.18c.6-1.37.9-2.92.28-4.32-.67-1.52-2.2-2.32-3.76-2.74.46.1.89.21 1.29.37 1.74.67 2.69 1.88 2.93 3.21.2 1.13-.05 2.25-.74 3.47V70Zm-27.47-4.14c-.12-.19-.23-.38-.34-.57.74.42.85.36.99.41v.08c-.22.03-.43.06-.65.08Zm11.21 30.46c-.08 1.08-.16 2.17-.33 3.24-.05.35-.11.69-.2 1.03 0 .04-.02.07-.03.11-.15.02-.3.04-.45.05.45-1.64.76-3.36.79-5.07.03-.29.08-.57.1-.89-.03-.31-.03-.47.24-.57-.04.69-.07 1.39-.12 2.08v.02Zm5.6-2.47c.48.11.92.52 2.49 1.72-.46-.32-.92-.65-1.38-.97-.37-.25-.73-.5-1.1-.75h-.01Zm21.23 7.24a70.76 70.76 0 0 1-4.37 4.63c-.14-.09-.27-.19-.4-.28.19-.09.37-.24.55-.47.87-1.14 5.43-5.51 5.49-7.45.31.26.62.53.92.79-.67.97-1.42 1.88-2.19 2.77v.01Z"
+ fill="currentColor"
+ transform="translate(-144.023 -51.76)"
+ />
+ </svg>
+ </div>
+ </div>
+ <div
+ class="welcome-screen-center__heading welcome-screen-decor excalifont"
+ >
+ All your data is saved locally in your browser.
+ </div>
+ <div
+ class="welcome-screen-menu"
+ >
+ <button
+ class="welcome-screen-menu-item "
+ type="button"
+ >
+ <div
+ class="welcome-screen-menu-item__icon"
+ >
+ <svg
+ aria-hidden="true"
+ class=""
+ fill="none"
+ focusable="false"
+ role="img"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ viewBox="0 0 20 20"
+ >
+ <path
+ d="m9.257 6.351.183.183H15.819c.34 0 .727.182 1.051.506.323.323.505.708.505 1.05v5.819c0 .316-.183.7-.52 1.035-.337.338-.723.522-1.037.522H4.182c-.352 0-.74-.181-1.058-.5-.318-.318-.499-.705-.499-1.057V5.182c0-.351.181-.736.5-1.054.32-.321.71-.503 1.057-.503H6.53l2.726 2.726Z"
+ stroke-width="1.25"
+ />
+ </svg>
+ </div>
+ <div
+ class="welcome-screen-menu-item__text"
+ >
+ Open
+ </div>
+ <div
+ class="welcome-screen-menu-item__shortcut"
+ >
+ Ctrl+O
+ </div>
+ </button>
+ <button
+ class="welcome-screen-menu-item "
+ type="button"
+ >
+ <div
+ class="welcome-screen-menu-item__icon"
+ >
+ <svg
+ aria-hidden="true"
+ class=""
+ fill="none"
+ focusable="false"
+ role="img"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ >
+ <g
+ stroke-width="1.5"
+ >
+ <path
+ d="M0 0h24v24H0z"
+ fill="none"
+ stroke="none"
+ />
+ <circle
+ cx="12"
+ cy="12"
+ r="9"
+ />
+ <line
+ x1="12"
+ x2="12"
+ y1="17"
+ y2="17.01"
+ />
+ <path
+ d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"
+ />
+ </g>
+ </svg>
+ </div>
+ <div
+ class="welcome-screen-menu-item__text"
+ >
+ Help
+ </div>
+ <div
+ class="welcome-screen-menu-item__shortcut"
+ >
+ ?
+ </div>
+ </button>
+ <button
+ class="welcome-screen-menu-item "
+ type="button"
+ >
+ <div
+ class="welcome-screen-menu-item__icon"
+ >
+ <svg
+ aria-hidden="true"
+ class=""
+ fill="none"
+ focusable="false"
+ role="img"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ >
+ <g
+ stroke-width="1.5"
+ >
+ <path
+ d="M0 0h24v24H0z"
+ fill="none"
+ stroke="none"
+ />
+ <circle
+ cx="9"
+ cy="7"
+ r="4"
+ />
+ <path
+ d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"
+ />
+ <path
+ d="M16 3.13a4 4 0 0 1 0 7.75"
+ />
+ <path
+ d="M21 21v-2a4 4 0 0 0 -3 -3.85"
+ />
+ </g>
+ </svg>
+ </div>
+ <div
+ class="welcome-screen-menu-item__text"
+ >
+ Live collaboration...
+ </div>
+ </button>
+ <a
+ class="welcome-screen-menu-item "
+ href="undefined/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
+ rel="noreferrer"
+ target="_blank"
+ >
+ <div
+ class="welcome-screen-menu-item__icon"
+ >
+ <svg
+ aria-hidden="true"
+ class=""
+ fill="none"
+ focusable="false"
+ role="img"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ >
+ <g
+ stroke-width="1.5"
+ >
+ <path
+ d="M0 0h24v24H0z"
+ fill="none"
+ stroke="none"
+ />
+ <path
+ d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"
+ />
+ <path
+ d="M21 12h-13l3 -3"
+ />
+ <path
+ d="M11 15l-3 -3"
+ />
+ </g>
+ </svg>
+ </div>
+ <div
+ class="welcome-screen-menu-item__text"
+ >
+ Sign up
+ </div>
+ </a>
+ </div>
+</div>
+`;
diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx
new file mode 100644
index 0000000..80cf4b0
--- /dev/null
+++ b/excalidraw-app/tests/collab.test.tsx
@@ -0,0 +1,247 @@
+import { vi } from "vitest";
+import { act, render, waitFor } from "@excalidraw/excalidraw/tests/test-utils";
+import ExcalidrawApp from "../App";
+import { API } from "@excalidraw/excalidraw/tests/helpers/api";
+import { syncInvalidIndices } from "@excalidraw/excalidraw/fractionalIndex";
+import {
+ createRedoAction,
+ createUndoAction,
+} from "@excalidraw/excalidraw/actions/actionHistory";
+import { CaptureUpdateAction, newElementWith } from "@excalidraw/excalidraw";
+
+const { h } = window;
+
+Object.defineProperty(window, "crypto", {
+ value: {
+ getRandomValues: (arr: number[]) =>
+ arr.forEach((v, i) => (arr[i] = Math.floor(Math.random() * 256))),
+ subtle: {
+ generateKey: () => {},
+ exportKey: () => ({ k: "sTdLvMC_M3V8_vGa3UVRDg" }),
+ },
+ },
+});
+
+vi.mock("../../excalidraw-app/data/firebase.ts", () => {
+ const loadFromFirebase = async () => null;
+ const saveToFirebase = () => {};
+ const isSavedToFirebase = () => true;
+ const loadFilesFromFirebase = async () => ({
+ loadedFiles: [],
+ erroredFiles: [],
+ });
+ const saveFilesToFirebase = async () => ({
+ savedFiles: new Map(),
+ erroredFiles: new Map(),
+ });
+
+ return {
+ loadFromFirebase,
+ saveToFirebase,
+ isSavedToFirebase,
+ loadFilesFromFirebase,
+ saveFilesToFirebase,
+ };
+});
+
+vi.mock("socket.io-client", () => {
+ return {
+ default: () => {
+ return {
+ close: () => {},
+ on: () => {},
+ once: () => {},
+ off: () => {},
+ emit: () => {},
+ };
+ },
+ };
+});
+
+/**
+ * These test would deserve to be extended by testing collab with (at least) two clients simultanouesly,
+ * while having access to both scenes, appstates stores, histories and etc.
+ * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously.
+ */
+describe("collaboration", () => {
+ it("should allow to undo / redo even on force-deleted elements", async () => {
+ await render(<ExcalidrawApp />);
+ const rect1Props = {
+ type: "rectangle",
+ id: "A",
+ height: 200,
+ width: 100,
+ } as const;
+
+ const rect2Props = {
+ type: "rectangle",
+ id: "B",
+ width: 100,
+ height: 200,
+ } as const;
+
+ const rect1 = API.createElement({ ...rect1Props });
+ const rect2 = API.createElement({ ...rect2Props });
+
+ API.updateScene({
+ elements: syncInvalidIndices([rect1, rect2]),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+
+ API.updateScene({
+ elements: syncInvalidIndices([
+ rect1,
+ newElementWith(h.elements[1], { isDeleted: true }),
+ ]),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(2);
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: true }),
+ ]);
+ expect(h.elements).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: true }),
+ ]);
+ });
+
+ // one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server
+ window.collab.startCollaboration(null);
+
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(2);
+ // we never delete from the local snapshot as it is used for correct diff calculation
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: true }),
+ ]);
+ expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
+ });
+
+ const undoAction = createUndoAction(h.history, h.store);
+ act(() => h.app.actionManager.executeAction(undoAction));
+
+ // with explicit undo (as addition) we expect our item to be restored from the snapshot!
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(1);
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: false }),
+ ]);
+ expect(h.elements).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: false }),
+ ]);
+ });
+
+ // simulate force deleting the element remotely
+ API.updateScene({
+ elements: syncInvalidIndices([rect1]),
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(1);
+ expect(API.getRedoStack().length).toBe(1);
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: true }),
+ ]);
+ expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
+ });
+
+ const redoAction = createRedoAction(h.history, h.store);
+ act(() => h.app.actionManager.executeAction(redoAction));
+
+ // with explicit redo (as removal) we again restore the element from the snapshot!
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(2);
+ expect(API.getRedoStack().length).toBe(0);
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: true }),
+ ]);
+ expect(h.elements).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: true }),
+ ]);
+ });
+
+ act(() => h.app.actionManager.executeAction(undoAction));
+
+ // simulate local update
+ API.updateScene({
+ elements: syncInvalidIndices([
+ h.elements[0],
+ newElementWith(h.elements[1], { x: 100 }),
+ ]),
+ captureUpdate: CaptureUpdateAction.IMMEDIATELY,
+ });
+
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(2);
+ expect(API.getRedoStack().length).toBe(0);
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
+ ]);
+ expect(h.elements).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }),
+ ]);
+ });
+
+ act(() => h.app.actionManager.executeAction(undoAction));
+
+ // we expect to iterate the stack to the first visible change
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(1);
+ expect(API.getRedoStack().length).toBe(1);
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
+ ]);
+ expect(h.elements).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }),
+ ]);
+ });
+
+ // simulate force deleting the element remotely
+ API.updateScene({
+ elements: syncInvalidIndices([rect1]),
+ captureUpdate: CaptureUpdateAction.NEVER,
+ });
+
+ // snapshot was correctly updated and marked the element as deleted
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(1);
+ expect(API.getRedoStack().length).toBe(1);
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining(rect1Props),
+ expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }),
+ ]);
+ expect(h.elements).toEqual([expect.objectContaining(rect1Props)]);
+ });
+
+ act(() => h.app.actionManager.executeAction(redoAction));
+
+ // with explicit redo (as update) we again restored the element from the snapshot!
+ await waitFor(() => {
+ expect(API.getUndoStack().length).toBe(2);
+ expect(API.getRedoStack().length).toBe(0);
+ expect(API.getSnapshot()).toEqual([
+ expect.objectContaining({ id: "A", isDeleted: false }),
+ expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
+ ]);
+ expect(h.history.isRedoStackEmpty).toBeTruthy();
+ expect(h.elements).toEqual([
+ expect.objectContaining({ id: "A", isDeleted: false }),
+ expect.objectContaining({ id: "B", isDeleted: true, x: 100 }),
+ ]);
+ });
+ });
+});
diff --git a/excalidraw-app/useHandleAppTheme.ts b/excalidraw-app/useHandleAppTheme.ts
new file mode 100644
index 0000000..e319611
--- /dev/null
+++ b/excalidraw-app/useHandleAppTheme.ts
@@ -0,0 +1,69 @@
+import { useEffect, useLayoutEffect, useState } from "react";
+import { THEME } from "@excalidraw/excalidraw";
+import { EVENT } from "@excalidraw/excalidraw/constants";
+import type { Theme } from "@excalidraw/excalidraw/element/types";
+import { CODES, KEYS } from "@excalidraw/excalidraw/keys";
+import { STORAGE_KEYS } from "./app_constants";
+
+const getDarkThemeMediaQuery = (): MediaQueryList | undefined =>
+ window.matchMedia?.("(prefers-color-scheme: dark)");
+
+export const useHandleAppTheme = () => {
+ const [appTheme, setAppTheme] = useState<Theme | "system">(() => {
+ return (
+ (localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) as
+ | Theme
+ | "system"
+ | null) || THEME.LIGHT
+ );
+ });
+ const [editorTheme, setEditorTheme] = useState<Theme>(THEME.LIGHT);
+
+ useEffect(() => {
+ const mediaQuery = getDarkThemeMediaQuery();
+
+ const handleChange = (e: MediaQueryListEvent) => {
+ setEditorTheme(e.matches ? THEME.DARK : THEME.LIGHT);
+ };
+
+ if (appTheme === "system") {
+ mediaQuery?.addEventListener("change", handleChange);
+ }
+
+ const handleKeydown = (event: KeyboardEvent) => {
+ if (
+ !event[KEYS.CTRL_OR_CMD] &&
+ event.altKey &&
+ event.shiftKey &&
+ event.code === CODES.D
+ ) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ setAppTheme(editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK);
+ }
+ };
+
+ document.addEventListener(EVENT.KEYDOWN, handleKeydown, { capture: true });
+
+ return () => {
+ mediaQuery?.removeEventListener("change", handleChange);
+ document.removeEventListener(EVENT.KEYDOWN, handleKeydown, {
+ capture: true,
+ });
+ };
+ }, [appTheme, editorTheme, setAppTheme]);
+
+ useLayoutEffect(() => {
+ localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, appTheme);
+
+ if (appTheme === "system") {
+ setEditorTheme(
+ getDarkThemeMediaQuery()?.matches ? THEME.DARK : THEME.LIGHT,
+ );
+ } else {
+ setEditorTheme(appTheme);
+ }
+ }, [appTheme]);
+
+ return { editorTheme, appTheme, setAppTheme };
+};
diff --git a/excalidraw-app/vite-env.d.ts b/excalidraw-app/vite-env.d.ts
new file mode 100644
index 0000000..ade60e8
--- /dev/null
+++ b/excalidraw-app/vite-env.d.ts
@@ -0,0 +1,49 @@
+/// <reference types="vite-plugin-pwa/vanillajs" />
+/// <reference types="vite-plugin-pwa/info" />
+/// <reference types="vite-plugin-svgr/client" />
+interface ImportMetaEnv {
+ // The port to run the dev server
+ VITE_APP_PORT: string;
+
+ VITE_APP_BACKEND_V2_GET_URL: string;
+ VITE_APP_BACKEND_V2_POST_URL: string;
+
+ // collaboration WebSocket server (https: string
+ VITE_APP_WS_SERVER_URL: string;
+
+ // set this only if using the collaboration workflow we use on excalidraw.com
+ VITE_APP_PORTAL_URL: string;
+ VITE_APP_AI_BACKEND: string;
+
+ VITE_APP_FIREBASE_CONFIG: string;
+
+ // whether to disable live reload / HMR. Usuaully what you want to do when
+ // debugging Service Workers.
+ VITE_APP_DEV_DISABLE_LIVE_RELOAD: string;
+
+ VITE_APP_DISABLE_SENTRY: string;
+
+ // Set this flag to false if you want to open the overlay by default
+ VITE_APP_COLLAPSE_OVERLAY: string;
+
+ // Enable eslint in dev server
+ VITE_APP_ENABLE_ESLINT: string;
+
+ // Enable PWA in dev server
+ VITE_APP_ENABLE_PWA: string;
+
+ VITE_APP_PLUS_LP: string;
+
+ VITE_APP_PLUS_APP: string;
+
+ VITE_APP_GIT_SHA: string;
+
+ MODE: string;
+
+ DEV: string;
+ PROD: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/excalidraw-app/vite.config.mts b/excalidraw-app/vite.config.mts
new file mode 100644
index 0000000..1689624
--- /dev/null
+++ b/excalidraw-app/vite.config.mts
@@ -0,0 +1,269 @@
+import path from "path";
+import { defineConfig, loadEnv } from "vite";
+import react from "@vitejs/plugin-react";
+import svgrPlugin from "vite-plugin-svgr";
+import { ViteEjsPlugin } from "vite-plugin-ejs";
+import { VitePWA } from "vite-plugin-pwa";
+import checker from "vite-plugin-checker";
+import { createHtmlPlugin } from "vite-plugin-html";
+import Sitemap from "vite-plugin-sitemap";
+import { woff2BrowserPlugin } from "../scripts/woff2/woff2-vite-plugins";
+export default defineConfig(({ mode }) => {
+ // To load .env variables
+ const envVars = loadEnv(mode, `../`);
+ // https://vitejs.dev/config/
+ return {
+ server: {
+ port: Number(envVars.VITE_APP_PORT || 3000),
+ // open the browser
+ open: true,
+ },
+ // We need to specify the envDir since now there are no
+ //more located in parallel with the vite.config.ts file but in parent dir
+ envDir: "../",
+ resolve: {
+ alias: [
+ {
+ find: /^@excalidraw\/excalidraw$/,
+ replacement: path.resolve(__dirname, "../packages/excalidraw/index.tsx"),
+ },
+ {
+ find: /^@excalidraw\/excalidraw\/(.*?)/,
+ replacement: path.resolve(__dirname, "../packages/excalidraw/$1"),
+ },
+ {
+ find: /^@excalidraw\/utils$/,
+ replacement: path.resolve(__dirname, "../packages/utils/index.ts"),
+ },
+ {
+ find: /^@excalidraw\/utils\/(.*?)/,
+ replacement: path.resolve(__dirname, "../packages/utils/$1"),
+ },
+ {
+ find: /^@excalidraw\/math$/,
+ replacement: path.resolve(__dirname, "../packages/math/index.ts"),
+ },
+ {
+ find: /^@excalidraw\/math\/(.*?)/,
+ replacement: path.resolve(__dirname, "../packages/math/$1"),
+ },
+ ],
+ },
+ build: {
+ outDir: "build",
+ rollupOptions: {
+ output: {
+ assetFileNames(chunkInfo) {
+ if (chunkInfo?.name?.endsWith(".woff2")) {
+ const family = chunkInfo.name.split("-")[0];
+ return `fonts/${family}/[name][extname]`;
+ }
+
+ return "assets/[name]-[hash][extname]";
+ },
+ // Creating separate chunk for locales except for en and percentages.json so they
+ // can be cached at runtime and not merged with
+ // app precache. en.json and percentages.json are needed for first load
+ // or fallback hence not clubbing with locales so first load followed by offline mode works fine. This is how CRA used to work too.
+ manualChunks(id) {
+ if (
+ id.includes("packages/excalidraw/locales") &&
+ id.match(/en.json|percentages.json/) === null
+ ) {
+ const index = id.indexOf("locales/");
+ // Taking the substring after "locales/"
+ return `locales/${id.substring(index + 8)}`;
+ }
+ },
+ },
+ },
+ sourcemap: mode !== "production",
+ // don't auto-inline small assets (i.e. fonts hosted on CDN)
+ assetsInlineLimit: 0,
+ },
+ plugins: [
+ Sitemap({
+ hostname: "https://kj-diagramming.local",
+ outDir: "build",
+ changefreq: "monthly",
+ // its static in public folder
+ generateRobotsTxt: false,
+ }),
+ woff2BrowserPlugin(),
+ react(),
+ checker({
+ typescript: true,
+ eslint:
+ envVars.VITE_APP_ENABLE_ESLINT === "false"
+ ? undefined
+ : { lintCommand: 'eslint "./**/*.{js,ts,tsx}"' },
+ overlay: {
+ initialIsOpen: envVars.VITE_APP_COLLAPSE_OVERLAY === "false",
+ badgeStyle: "margin-bottom: 4rem; margin-left: 1rem",
+ },
+ }),
+ svgrPlugin(),
+ ViteEjsPlugin(),
+ VitePWA({
+ registerType: "autoUpdate",
+ devOptions: {
+ /* set this flag to true to enable in Development mode */
+ enabled: envVars.VITE_APP_ENABLE_PWA === "true",
+ },
+
+ workbox: {
+ // don't precache fonts, locales and separate chunks
+ globIgnores: [
+ "fonts.css",
+ "**/locales/**",
+ "service-worker.js",
+ "**/*.chunk-*.js",
+ ],
+ runtimeCaching: [
+ {
+ urlPattern: new RegExp(".+.woff2"),
+ handler: "CacheFirst",
+ options: {
+ cacheName: "fonts",
+ expiration: {
+ maxEntries: 1000,
+ maxAgeSeconds: 60 * 60 * 24 * 90, // 90 days
+ },
+ cacheableResponse: {
+ // 0 to cache "opaque" responses from cross-origin requests (i.e. CDN)
+ statuses: [0, 200],
+ },
+ },
+ },
+ {
+ urlPattern: new RegExp("fonts.css"),
+ handler: "StaleWhileRevalidate",
+ options: {
+ cacheName: "fonts",
+ expiration: {
+ maxEntries: 50,
+ },
+ },
+ },
+ {
+ urlPattern: new RegExp("locales/[^/]+.js"),
+ handler: "CacheFirst",
+ options: {
+ cacheName: "locales",
+ expiration: {
+ maxEntries: 50,
+ maxAgeSeconds: 60 * 60 * 24 * 30, // <== 30 days
+ },
+ },
+ },
+ {
+ urlPattern: new RegExp(".chunk-.+.js"),
+ handler: "CacheFirst",
+ options: {
+ cacheName: "chunk",
+ expiration: {
+ maxEntries: 50,
+ maxAgeSeconds: 60 * 60 * 24 * 90, // <== 90 days
+ },
+ },
+ },
+ ],
+ },
+ manifest: {
+ short_name: "kj-diagramming",
+ name: "kj-diagramming",
+ description:
+ "kj-diagramming is a whiteboard tool (an excalidraw fork) that lets you easily sketch diagrams with a hand-drawn feel.",
+ icons: [
+ {
+ src: "android-chrome-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ },
+ {
+ src: "apple-touch-icon.png",
+ type: "image/png",
+ sizes: "180x180",
+ },
+ {
+ src: "favicon-32x32.png",
+ sizes: "32x32",
+ type: "image/png",
+ },
+ {
+ src: "favicon-16x16.png",
+ sizes: "16x16",
+ type: "image/png",
+ },
+ ],
+ start_url: "/",
+ id:"kj-diagramming",
+ display: "standalone",
+ theme_color: "#121212",
+ background_color: "#ffffff",
+ file_handlers: [
+ {
+ action: "/",
+ accept: {
+ "application/vnd.excalidraw+json": [".excalidraw"],
+ },
+ },
+ ],
+ share_target: {
+ action: "/web-share-target",
+ method: "POST",
+ enctype: "multipart/form-data",
+ params: {
+ files: [
+ {
+ name: "file",
+ accept: [
+ "application/vnd.excalidraw+json",
+ "application/json",
+ ".excalidraw",
+ ],
+ },
+ ],
+ },
+ },
+ screenshots: [
+ {
+ src: "/screenshots/virtual-whiteboard.png",
+ type: "image/png",
+ sizes: "462x945",
+ },
+ {
+ src: "/screenshots/wireframe.png",
+ type: "image/png",
+ sizes: "462x945",
+ },
+ {
+ src: "/screenshots/illustration.png",
+ type: "image/png",
+ sizes: "462x945",
+ },
+ {
+ src: "/screenshots/shapes.png",
+ type: "image/png",
+ sizes: "462x945",
+ },
+ {
+ src: "/screenshots/collaboration.png",
+ type: "image/png",
+ sizes: "462x945",
+ },
+ {
+ src: "/screenshots/export.png",
+ type: "image/png",
+ sizes: "462x945",
+ },
+ ],
+ },
+ }),
+ createHtmlPlugin({
+ minify: true,
+ }),
+ ],
+ publicDir: "../public",
+ };
+});