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