From bfc2cec7d43eb8eaa46dd3f91084932381257059 Mon Sep 17 00:00:00 2001 From: kj_sh604 Date: Sun, 15 Mar 2026 16:19:35 -0400 Subject: refactor: excalidraw-app/ --- excalidraw-app/App.tsx | 1154 ++++++++++++++++++++ excalidraw-app/CustomStats.tsx | 85 ++ excalidraw-app/ExcalidrawPlusIframeExport.tsx | 222 ++++ excalidraw-app/app-jotai.ts | 37 + excalidraw-app/app-language/LanguageList.tsx | 25 + excalidraw-app/app-language/language-detector.ts | 25 + excalidraw-app/app-language/language-state.ts | 15 + excalidraw-app/app_constants.ts | 59 + excalidraw-app/bug-issue-template.js | 11 + excalidraw-app/collab/Collab.tsx | 1018 +++++++++++++++++ excalidraw-app/collab/CollabError.scss | 35 + excalidraw-app/collab/CollabError.tsx | 54 + excalidraw-app/collab/Portal.tsx | 256 +++++ excalidraw-app/components/AI.tsx | 159 +++ excalidraw-app/components/AppFooter.tsx | 29 + excalidraw-app/components/AppMainMenu.tsx | 86 ++ excalidraw-app/components/AppWelcomeScreen.tsx | 72 ++ excalidraw-app/components/DebugCanvas.tsx | 344 ++++++ excalidraw-app/components/EncryptedIcon.tsx | 21 + .../components/ExcalidrawPlusAppLink.tsx | 19 + .../components/ExportToExcalidrawPlus.tsx | 133 +++ excalidraw-app/components/GitHubCorner.tsx | 45 + excalidraw-app/components/TopErrorBoundary.tsx | 146 +++ excalidraw-app/data/FileManager.ts | 273 +++++ excalidraw-app/data/LocalData.ts | 258 +++++ excalidraw-app/data/Locker.ts | 18 + excalidraw-app/data/firebase.ts | 313 ++++++ excalidraw-app/data/index.ts | 338 ++++++ excalidraw-app/data/localStorage.ts | 99 ++ excalidraw-app/data/tabSync.ts | 39 + excalidraw-app/debug.ts | 135 +++ excalidraw-app/global.d.ts | 6 + excalidraw-app/index.html | 252 +++++ excalidraw-app/index.scss | 117 ++ excalidraw-app/index.tsx | 15 + excalidraw-app/package.json | 56 + excalidraw-app/sentry.ts | 81 ++ excalidraw-app/share/ShareDialog.scss | 166 +++ excalidraw-app/share/ShareDialog.tsx | 290 +++++ excalidraw-app/tests/LanguageList.test.tsx | 34 + excalidraw-app/tests/MobileMenu.test.tsx | 51 + .../tests/__snapshots__/MobileMenu.test.tsx.snap | 247 +++++ excalidraw-app/tests/collab.test.tsx | 247 +++++ excalidraw-app/useHandleAppTheme.ts | 69 ++ excalidraw-app/vite-env.d.ts | 49 + excalidraw-app/vite.config.mts | 269 +++++ 46 files changed, 7472 insertions(+) create mode 100644 excalidraw-app/App.tsx create mode 100644 excalidraw-app/CustomStats.tsx create mode 100644 excalidraw-app/ExcalidrawPlusIframeExport.tsx create mode 100644 excalidraw-app/app-jotai.ts create mode 100644 excalidraw-app/app-language/LanguageList.tsx create mode 100644 excalidraw-app/app-language/language-detector.ts create mode 100644 excalidraw-app/app-language/language-state.ts create mode 100644 excalidraw-app/app_constants.ts create mode 100644 excalidraw-app/bug-issue-template.js create mode 100644 excalidraw-app/collab/Collab.tsx create mode 100644 excalidraw-app/collab/CollabError.scss create mode 100644 excalidraw-app/collab/CollabError.tsx create mode 100644 excalidraw-app/collab/Portal.tsx create mode 100644 excalidraw-app/components/AI.tsx create mode 100644 excalidraw-app/components/AppFooter.tsx create mode 100644 excalidraw-app/components/AppMainMenu.tsx create mode 100644 excalidraw-app/components/AppWelcomeScreen.tsx create mode 100644 excalidraw-app/components/DebugCanvas.tsx create mode 100644 excalidraw-app/components/EncryptedIcon.tsx create mode 100644 excalidraw-app/components/ExcalidrawPlusAppLink.tsx create mode 100644 excalidraw-app/components/ExportToExcalidrawPlus.tsx create mode 100644 excalidraw-app/components/GitHubCorner.tsx create mode 100644 excalidraw-app/components/TopErrorBoundary.tsx create mode 100644 excalidraw-app/data/FileManager.ts create mode 100644 excalidraw-app/data/LocalData.ts create mode 100644 excalidraw-app/data/Locker.ts create mode 100644 excalidraw-app/data/firebase.ts create mode 100644 excalidraw-app/data/index.ts create mode 100644 excalidraw-app/data/localStorage.ts create mode 100644 excalidraw-app/data/tabSync.ts create mode 100644 excalidraw-app/debug.ts create mode 100644 excalidraw-app/global.d.ts create mode 100644 excalidraw-app/index.html create mode 100644 excalidraw-app/index.scss create mode 100644 excalidraw-app/index.tsx create mode 100644 excalidraw-app/package.json create mode 100644 excalidraw-app/sentry.ts create mode 100644 excalidraw-app/share/ShareDialog.scss create mode 100644 excalidraw-app/share/ShareDialog.tsx create mode 100644 excalidraw-app/tests/LanguageList.test.tsx create mode 100644 excalidraw-app/tests/MobileMenu.test.tsx create mode 100644 excalidraw-app/tests/__snapshots__/MobileMenu.test.tsx.snap create mode 100644 excalidraw-app/tests/collab.test.tsx create mode 100644 excalidraw-app/useHandleAppTheme.ts create mode 100644 excalidraw-app/vite-env.d.ts create mode 100644 excalidraw-app/vite.config.mts 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; + userChoice: Promise; + } + + 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: ( + {text}} + 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; + }>({ promise: null! }); + if (!initialStatePromiseRef.current.promise) { + initialStatePromiseRef.current.promise = + resolvablePromise(); + } + + const debugCanvasRef = useRef(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(); + + 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, + 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( + null, + ); + + const onExportToBackend = async ( + exportedElements: readonly NonDeletedExcalidrawElement[], + appState: Partial, + 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 ( + 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 ( +
+

I'm not a pretzel!

+
+ ); + } + + const ExcalidrawPlusCommand = { + label: "kj-diagramming cloud", + category: DEFAULT_CATEGORIES.links, + predicate: true, + icon:
{ExcalLogo}
, + 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:
{ExcalLogo}
, + 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 ( +
+ { + return ( + { + 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 ( +
+ {collabError.message && } + + setShareDialogState({ isOpen: true, type: "share" }) + } + /> +
+ ); + }} + onLinkOpen={(element, event) => { + if (element.link && isElementLink(element.link)) { + event.preventDefault(); + excalidrawAPI?.scrollToContent(element.link, { animate: true }); + } + }} + > + setAppTheme(theme)} + refresh={() => forceRefresh((prev) => !prev)} + /> + + + + + {excalidrawAPI && ( + { + exportToExcalidrawPlus( + excalidrawAPI.getSceneElements(), + excalidrawAPI.getAppState(), + excalidrawAPI.getFiles(), + excalidrawAPI.getName(), + ); + }} + > + {t("overwriteConfirm.action.excalidrawPlus.description")} + + )} + + excalidrawAPI?.refresh()} /> + {excalidrawAPI && } + + + {isCollaborating && isOffline && ( +
+ {t("alerts.collabOfflineWarning")} +
+ )} + {latestShareableLink && ( + setLatestShareableLink(null)} + setErrorMessage={setErrorMessage} + /> + )} + {excalidrawAPI && !isCollabDisabled && ( + + )} + + { + if (excalidrawAPI) { + try { + await onExportToBackend( + excalidrawAPI.getSceneElements(), + excalidrawAPI.getAppState(), + excalidrawAPI.getFiles(), + ); + } catch (error: any) { + setErrorMessage(error.message); + } + } + }} + /> + + {errorMessage && ( + setErrorMessage("")}> + {errorMessage} + + )} + + { + 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 && ( + + )} +
+
+ ); +}; + +const ExcalidrawApp = () => { + const isCloudExportWindow = + window.location.pathname === "/excalidraw-plus-export"; + if (isCloudExportWindow) { + return ; + } + + return ( + + + + + + ); +}; + +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({ + 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 ( + + {t("stats.version")} + { + try { + await copyTextToSystemClipboard(getVersion()); + props.setToast(t("toast.copyToClipboard")); + } catch {} + }} + title={t("stats.versionCopy")} + > + {timestamp} +
+ {hash} +
+ + {t("stats.storage")} + +
{t("stats.scene")}
+
{nFormatter(storageSizes.scene, 1)}
+
+ +
{t("stats.total")}
+
{nFormatter(storageSizes.total, 1)}
+
+
+ ); +}; + +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; + files: { loadedFiles: BinaryFileData[]; erroredFiles: Map }; +}; + +type MESSAGE_FROM_EDITOR = MESSAGE_ERROR | MESSAGE_SCENE_DATA | MESSAGE_READY; +// ----------------------------------------------------------------------------- + +const parseSceneData = async ({ + rawElementsString, + rawAppStateString, +}: { + rawElementsString: string | null; + rawAppStateString: string | null; +}): Promise => { + 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) => { + 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, +>( + 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 ( + + ); +}; 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(null); +export const isCollaboratingAtom = atom(false); +export const isOfflineAtom = atom(false); + +interface CollabState { + errorMessage: string | null; + /** errors related to saving */ + dialogNotifiedErrors: Record; + username: string; + activeRoomLink: string | null; +} + +export const activeRoomLinkAtom = atom(null); + +type CollabInstance = InstanceType; + +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 { + portal: Portal; + fileManager: FileManager; + excalidrawAPI: CollabProps["excalidrawAPI"]; + activeIntervalId: number | null; + idleTimeoutId: number | null; + + private socketInitializationTimer?: number; + private lastBroadcastedOrReceivedSceneVersion: number = -1; + private collaborators = new Map(); + + 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, id) => { + const fileData = addedFiles.get(id); + if (fileData) { + acc.set(id, fileData); + } + return acc; + }, + new Map(), + ), + erroredFiles: erroredFiles.reduce( + (acc: Map, 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> => { + 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["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) => { + const collaborators = new Map(this.collaborators); + const user: Mutable = 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 && ( + this.setErrorDialog(null)}> + {errorMessage} + + )} + + ); + } +} + +declare global { + interface Window { + collab: InstanceType; + } +} + +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({ + message: null, + nonce: 0, +}); + +const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => { + const [isAnimating, setIsAnimating] = useState(false); + const clearAnimationRef = useRef(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 ( + +
+ {warning} +
+
+ ); +}; + +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 = 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 ( + <> + { + 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: ` + +
+
Too many requests today,
please try again tomorrow!
+
+
+
You can also try kj-diagramming cloud to get more requests.
+
+ + `, + }; + } + + 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)"); + } + }} + /> + + { + 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 ( +
+
+ {isVisualDebuggerEnabled() && } + {isExcalidrawPlusSignedUser ? ( + + ) : ( + + )} +
+
+ ); + }, +); 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 ( + + + + + + {props.isCollabEnabled && ( + props.onCollabDialogOpen()} + /> + )} + + + + + + + kj-diagramming cloud + + + + {isExcalidrawPlusSignedUser ? "Sign in" : "Sign up"} + + {import.meta.env.DEV && ( + { + if (window.visualDebug) { + delete window.visualDebug; + saveDebugState({ enabled: false }); + } else { + window.visualDebug = { data: [] }; + saveDebugState({ enabled: true }); + } + props?.refresh(); + }} + > + Visual Debug + + )} + + + + + + + + ); +}); 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 ( + + kj-diagramming + + ); + } + return bit; + }); + } else { + headingContent = t("welcomeScreen.app.center_heading"); + } + + return ( + + + {t("welcomeScreen.app.menuHint")} + + + + + + {headingContent} + + + + + {props.isCollabEnabled && ( + props.onCollabDialogOpen()} + /> + )} + {!isExcalidrawPlusSignedUser && ( + + Sign up + + )} + + + + ); +}); 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, + 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, + 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, + el.color, + ); + break; + case isCurve(el.data): + renderCubicBezier( + context, + appState.zoom.value, + el.data as Curve, + 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 ( + <> + + + + + + ); +}; + +interface DebugCanvasProps { + appState: AppState; + scale: number; + ref?: React.Ref; +} + +const DebugCanvas = ({ appState, scale, ref }: DebugCanvasProps) => { + const { width, height } = appState; + + const canvasRef = useRef(null); + useImperativeHandle( + ref, + () => canvasRef.current, + [canvasRef], + ); + + return ( + + Debug 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 ( + + + {shield} + + + ); +}; 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 ( + + Go to kj-diagramming cloud + + ); +}; 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, + 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(); + 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; + files: BinaryFiles; + name: string; + onError: (error: Error) => void; + onSuccess: () => void; +}> = ({ elements, appState, files, name, onError, onSuccess }) => { + const { t } = useI18n(); + return ( + +
+ +
+

Excalidraw+

+
+ {t("exportDialog.excalidrawplus_description")} +
+ { + 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"))); + } + } + }} + /> +
+ ); +}; 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 }) => ( + + + + + + + + ), +); 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) { + 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 ( +
+
+
+ ( + + )} + /> +
+
+ ( + + )} + /> +
+
+ + ⚠️ + + {t("errorSplash.clearCanvasCaveat")} + +
+
+
+
+ {t("errorSplash.trackedToSentry", { + eventId: this.state.sentryEventId, + })} +
+
+ ( + + )} + /> +
+
+
+ +