diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 972737b9d..7517bb379 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -104,6 +104,7 @@ import { openConfirmModal } from "../packages/excalidraw/components/OverwriteCon import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; import Trans from "../packages/excalidraw/components/Trans"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; +import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; polyfill(); @@ -310,6 +311,7 @@ const ExcalidrawWrapper = () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { return isCollaborationLink(window.location.href); }); + const collabError = useAtomValue(collabErrorIndicatorAtom); useHandleLibrary({ excalidrawAPI, @@ -748,12 +750,15 @@ const ExcalidrawWrapper = () => { return null; } return ( - - setShareDialogState({ isOpen: true, type: "share" }) - } - /> +
+ {collabError.message && } + + setShareDialogState({ isOpen: true, type: "share" }) + } + /> +
); }} > diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 14538b674..f7879c64e 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -81,6 +81,7 @@ import { appJotaiStore } from "../app-jotai"; import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; +import { collabErrorIndicatorAtom } from "./CollabError"; export const collabAPIAtom = atom(null); export const isCollaboratingAtom = atom(false); @@ -88,6 +89,8 @@ export const isOfflineAtom = atom(false); interface CollabState { errorMessage: string | null; + /** errors related to saving */ + dialogNotifiedErrors: Record; username: string; activeRoomLink: string | null; } @@ -107,7 +110,7 @@ export interface CollabAPI { setUsername: CollabInstance["setUsername"]; getUsername: CollabInstance["getUsername"]; getActiveRoomLink: CollabInstance["getActiveRoomLink"]; - setErrorMessage: CollabInstance["setErrorMessage"]; + setCollabError: CollabInstance["setErrorDialog"]; } interface CollabProps { @@ -129,6 +132,7 @@ class Collab extends PureComponent { super(props); this.state = { errorMessage: null, + dialogNotifiedErrors: {}, username: importUsernameFromLocalStorage() || "", activeRoomLink: null, }; @@ -197,7 +201,7 @@ class Collab extends PureComponent { setUsername: this.setUsername, getUsername: this.getUsername, getActiveRoomLink: this.getActiveRoomLink, - setErrorMessage: this.setErrorMessage, + setCollabError: this.setErrorDialog, }; appJotaiStore.set(collabAPIAtom, collabAPI); @@ -276,18 +280,35 @@ class Collab extends PureComponent { this.excalidrawAPI.getAppState(), ); + this.resetErrorIndicator(); + if (this.isCollaborating() && savedData && savedData.reconciledElements) { this.handleRemoteSceneUpdate( this.reconcileElements(savedData.reconciledElements), ); } } catch (error: any) { - this.setState({ - // firestore doesn't return a specific error code when size exceeded - errorMessage: /is longer than.*?bytes/.test(error.message) - ? t("errors.collabSaveFailed_sizeExceeded") - : t("errors.collabSaveFailed"), - }); + 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); } }; @@ -296,6 +317,7 @@ class Collab extends PureComponent { this.queueBroadcastAllElements.cancel(); this.queueSaveToFirebase.cancel(); this.loadImageFiles.cancel(); + this.resetErrorIndicator(true); this.saveCollabRoomToFirebase( getSyncableElements( @@ -464,7 +486,7 @@ class Collab extends PureComponent { this.portal.socket.once("connect_error", fallbackInitializationHandler); } catch (error: any) { console.error(error); - this.setState({ errorMessage: error.message }); + this.setErrorDialog(error.message); return null; } @@ -923,8 +945,26 @@ class Collab extends PureComponent { getActiveRoomLink = () => this.state.activeRoomLink; - setErrorMessage = (errorMessage: string | null) => { - this.setState({ errorMessage }); + 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() { @@ -933,7 +973,7 @@ class Collab extends PureComponent { return ( <> {errorMessage != null && ( - this.setState({ errorMessage: null })}> + this.setErrorDialog(null)}> {errorMessage} )} diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss new file mode 100644 index 000000000..085dc5609 --- /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(0eg); + } + 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 000000000..45a98ac8d --- /dev/null +++ b/excalidraw-app/collab/CollabError.tsx @@ -0,0 +1,54 @@ +import { Tooltip } from "../../packages/excalidraw/components/Tooltip"; +import { warning } from "../../packages/excalidraw/components/icons"; +import clsx from "clsx"; +import { useEffect, useRef, useState } from "react"; + +import "./CollabError.scss"; +import { atom } from "jotai"; + +type ErrorIndicator = { + message: string | null; + /** used to rerun the useEffect responsible for animation */ + nonce: number; +}; + +export const collabErrorIndicatorAtom = atom({ + message: null, + nonce: 0, +}); + +const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => { + const [isAnimating, setIsAnimating] = useState(false); + const clearAnimationRef = useRef(); + + useEffect(() => { + setIsAnimating(true); + clearAnimationRef.current = setTimeout(() => { + setIsAnimating(false); + }, 1000); + + return () => { + clearTimeout(clearAnimationRef.current); + }; + }, [collabError.message, collabError.nonce]); + + if (!collabError.message) { + return null; + } + + return ( + +
+ {warning} +
+
+ ); +}; + +CollabError.displayName = "CollabError"; + +export default CollabError; diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index d7ab79836..021442753 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -4,6 +4,13 @@ &.theme--dark { --color-primary-contrast-offset: #726dff; // to offset Chubb illusion } + + .top-right-ui { + display: flex; + justify-content: center; + align-items: center; + } + .footer-center { justify-content: flex-end; margin-top: auto; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx index 85e500dae..68096417b 100644 --- a/excalidraw-app/share/ShareDialog.tsx +++ b/excalidraw-app/share/ShareDialog.tsx @@ -70,7 +70,7 @@ const ActiveRoomDialog = ({ try { await copyTextToSystemClipboard(activeRoomLink); } catch (e) { - collabAPI.setErrorMessage(t("errors.copyToSystemClipboardFailed")); + collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed")); } setJustCopied(true); diff --git a/packages/excalidraw/components/Toast.tsx b/packages/excalidraw/components/Toast.tsx index be0c46663..2f0852a5d 100644 --- a/packages/excalidraw/components/Toast.tsx +++ b/packages/excalidraw/components/Toast.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { CSSProperties, useCallback, useEffect, useRef } from "react"; import { CloseIcon } from "./icons"; import "./Toast.scss"; import { ToolButton } from "./ToolButton"; @@ -11,11 +11,13 @@ export const Toast = ({ closable = false, // To prevent autoclose, pass duration as Infinity duration = DEFAULT_TOAST_TIMEOUT, + style, }: { message: string; onClose: () => void; closable?: boolean; duration?: number; + style?: CSSProperties; }) => { const timerRef = useRef(0); const shouldAutoClose = duration !== Infinity; @@ -43,6 +45,7 @@ export const Toast = ({ className="Toast" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + style={style} >

{message}

{closable && ( diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index fcf8df4a6..967ae1976 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -604,6 +604,10 @@ export const share = createIcon( modifiedTablerIconProps, ); +export const warning = createIcon( + "M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z", +); + export const shareIOS = createIcon( "M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z", { width: 24, height: 24 },