Преглед на файлове

feat: overwrite confirmation dialogs (#6658)

Co-authored-by: dwelle <[email protected]>
Are преди 2 години
родител
ревизия
7558a4e2be

+ 20 - 4
src/components/Dialog.tsx

@@ -17,16 +17,34 @@ import { useSetAtom } from "jotai";
 import { isLibraryMenuOpenAtom } from "./LibraryMenu";
 import { jotaiScope } from "../jotai";
 
+export type DialogSize = number | "small" | "regular" | "wide" | undefined;
+
 export interface DialogProps {
   children: React.ReactNode;
   className?: string;
-  size?: "small" | "regular" | "wide";
+  size?: DialogSize;
   onCloseRequest(): void;
   title: React.ReactNode | false;
   autofocus?: boolean;
   closeOnClickOutside?: boolean;
 }
 
+function getDialogSize(size: DialogSize): number {
+  if (size && typeof size === "number") {
+    return size;
+  }
+
+  switch (size) {
+    case "small":
+      return 550;
+    case "wide":
+      return 1024;
+    case "regular":
+    default:
+      return 800;
+  }
+}
+
 export const Dialog = (props: DialogProps) => {
   const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
   const [lastActiveElement] = useState(document.activeElement);
@@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => {
     <Modal
       className={clsx("Dialog", props.className)}
       labelledBy="dialog-title"
-      maxWidth={
-        props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
-      }
+      maxWidth={getDialogSize(props.size)}
       onCloseRequest={onClose}
       closeOnClickOutside={props.closeOnClickOutside}
     >

+ 137 - 41
src/components/FilledButton.scss

@@ -2,20 +2,140 @@
 
 .excalidraw {
   .ExcButton {
+    --text-color: transparent;
+    --border-color: transparent;
+    --back-color: transparent;
+
+    color: var(--text-color);
+    background-color: var(--back-color);
+    border-color: var(--border-color);
+
     &--color-primary {
-      color: var(--input-bg-color);
+      &.ExcButton--variant-filled {
+        --text-color: var(--input-bg-color);
+        --back-color: var(--color-primary);
+
+        &:hover {
+          --back-color: var(--color-primary-darker);
+        }
+
+        &:active {
+          --back-color: var(--color-primary-darkest);
+        }
+      }
 
-      --accent-color: var(--color-primary);
-      --accent-color-hover: var(--color-primary-darker);
-      --accent-color-active: var(--color-primary-darkest);
+      &.ExcButton--variant-outlined,
+      &.ExcButton--variant-icon {
+        --text-color: var(--color-primary);
+        --border-color: var(--color-primary);
+        --back-color: var(--input-bg-color);
+
+        &:hover {
+          --text-color: var(--color-primary-darker);
+          --border-color: var(--color-primary-darker);
+        }
+
+        &:active {
+          --text-color: var(--color-primary-darkest);
+          --border-color: var(--color-primary-darkest);
+        }
+      }
     }
 
     &--color-danger {
-      color: var(--input-bg-color);
+      &.ExcButton--variant-filled {
+        --text-color: var(--color-danger-text);
+        --back-color: var(--color-danger-dark);
+
+        &:hover {
+          --back-color: var(--color-danger-darker);
+        }
+
+        &:active {
+          --back-color: var(--color-danger-darkest);
+        }
+      }
+
+      &.ExcButton--variant-outlined,
+      &.ExcButton--variant-icon {
+        --text-color: var(--color-danger);
+        --border-color: var(--color-danger);
+        --back-color: transparent;
+
+        &:hover {
+          --text-color: var(--color-danger-darkest);
+          --border-color: var(--color-danger-darkest);
+        }
+
+        &:active {
+          --text-color: var(--color-danger-darker);
+          --border-color: var(--color-danger-darker);
+        }
+      }
+    }
+
+    &--color-muted {
+      &.ExcButton--variant-filled {
+        --text-color: var(--island-bg-color);
+        --back-color: var(--color-gray-50);
+
+        &:hover {
+          --back-color: var(--color-gray-60);
+        }
+
+        &:active {
+          --back-color: var(--color-gray-80);
+        }
+      }
+
+      &.ExcButton--variant-outlined,
+      &.ExcButton--variant-icon {
+        --text-color: var(--color-muted-background);
+        --border-color: var(--color-muted);
+        --back-color: var(--island-bg-color);
+
+        &:hover {
+          --text-color: var(--color-muted-background-darker);
+          --border-color: var(--color-muted-darker);
+        }
+
+        &:active {
+          --text-color: var(--color-muted-background-darker);
+          --border-color: var(--color-muted-darkest);
+        }
+      }
+    }
+
+    &--color-warning {
+      &.ExcButton--variant-filled {
+        --text-color: black;
+        --back-color: var(--color-warning-dark);
 
-      --accent-color: var(--color-danger);
-      --accent-color-hover: #d65550;
-      --accent-color-active: #d1413c;
+        &:hover {
+          --back-color: var(--color-warning-darker);
+        }
+
+        &:active {
+          --back-color: var(--color-warning-darkest);
+        }
+      }
+
+      &.ExcButton--variant-outlined,
+      &.ExcButton--variant-icon {
+        --text-color: var(--color-warning-dark);
+        --border-color: var(--color-warning-dark);
+        --back-color: var(--input-bg-color);
+
+        &:hover {
+          --text-color: var(--color-warning-darker);
+          --border-color: var(--color-warning-darker);
+        }
+
+        &:active {
+          --text-color: var(--color-warning-darkest);
+          --border-color: var(--color-warning-darkest);
+        }
+      }
     }
 
     display: flex;
@@ -25,6 +145,8 @@
     flex-wrap: nowrap;
 
     border-radius: 0.5rem;
+    border-width: 1px;
+    border-style: solid;
 
     font-family: "Assistant";
 
@@ -33,9 +155,9 @@
     transition: all 150ms ease-out;
 
     &--size-large {
-      font-weight: 400;
+      font-weight: 600;
       font-size: 0.875rem;
-      height: 3rem;
+      min-height: 3rem;
       padding: 0.5rem 1.5rem;
       gap: 0.75rem;
 
@@ -45,48 +167,22 @@
     &--size-medium {
       font-weight: 600;
       font-size: 0.75rem;
-      height: 2.5rem;
+      min-height: 2.5rem;
       padding: 0.5rem 1rem;
       gap: 0.5rem;
 
       letter-spacing: normal;
     }
 
-    &--variant-filled {
-      background: var(--accent-color);
-      border: 1px solid transparent;
-
-      &:hover {
-        background: var(--accent-color-hover);
-      }
-
-      &:active {
-        background: var(--accent-color-active);
-      }
-    }
-
-    &--variant-outlined,
-    &--variant-icon {
-      border: 1px solid var(--accent-color);
-      color: var(--accent-color);
-      background: transparent;
-
-      &:hover {
-        border: 1px solid var(--accent-color-hover);
-        color: var(--accent-color-hover);
-      }
-
-      &:active {
-        border: 1px solid var(--accent-color-active);
-        color: var(--accent-color-active);
-      }
-    }
-
     &--variant-icon {
       padding: 0.5rem 0.75rem;
       width: 3rem;
     }
 
+    &--fullWidth {
+      width: 100%;
+    }
+
     &__icon {
       width: 1.25rem;
       height: 1.25rem;

+ 4 - 1
src/components/FilledButton.tsx

@@ -4,7 +4,7 @@ import clsx from "clsx";
 import "./FilledButton.scss";
 
 export type ButtonVariant = "filled" | "outlined" | "icon";
-export type ButtonColor = "primary" | "danger";
+export type ButtonColor = "primary" | "danger" | "warning" | "muted";
 export type ButtonSize = "medium" | "large";
 
 export type FilledButtonProps = {
@@ -17,6 +17,7 @@ export type FilledButtonProps = {
   color?: ButtonColor;
   size?: ButtonSize;
   className?: string;
+  fullWidth?: boolean;
 
   startIcon?: React.ReactNode;
 };
@@ -31,6 +32,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
       variant = "filled",
       color = "primary",
       size = "medium",
+      fullWidth,
       className,
     },
     ref,
@@ -42,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
           `ExcButton--color-${color}`,
           `ExcButton--variant-${variant}`,
           `ExcButton--size-${size}`,
+          { "ExcButton--fullWidth": fullWidth },
           className,
         )}
         onClick={onClick}

+ 12 - 0
src/components/LayerUI.tsx

@@ -41,6 +41,7 @@ import { jotaiScope } from "../jotai";
 import { Provider, useAtom, useAtomValue } from "jotai";
 import MainMenu from "./main-menu/MainMenu";
 import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
+import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
 import { HandButton } from "./HandButton";
 import { isHandToolActive } from "../appState";
 import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
@@ -99,6 +100,15 @@ const DefaultMainMenu: React.FC<{
   );
 };
 
+const DefaultOverwriteConfirmDialog = () => {
+  return (
+    <OverwriteConfirmDialog __fallback>
+      <OverwriteConfirmDialog.Actions.SaveToDisk />
+      <OverwriteConfirmDialog.Actions.ExportToImage />
+    </OverwriteConfirmDialog>
+  );
+};
+
 const LayerUI = ({
   actionManager,
   appState,
@@ -343,6 +353,7 @@ const LayerUI = ({
       >
         {t("toolBar.library")}
       </DefaultSidebar.Trigger>
+      <DefaultOverwriteConfirmDialog />
       {/* ------------------------------------------------------------------ */}
 
       {appState.isLoading && <LoadingMessage delay={250} />}
@@ -374,6 +385,7 @@ const LayerUI = ({
         />
       )}
       <ActiveConfirmDialog />
+      <tunnels.OverwriteConfirmDialogTunnel.Out />
       {renderImageExportDialog()}
       {renderJSONExportDialog()}
       {appState.pasteDialog.shown && (

+ 1 - 1
src/components/Modal.scss

@@ -3,7 +3,7 @@
 .excalidraw {
   &.excalidraw-modal-container {
     position: absolute;
-    z-index: 10;
+    z-index: var(--zIndex-modal);
   }
 
   .Modal {

+ 126 - 0
src/components/OverwriteConfirm/OverwriteConfirm.scss

@@ -0,0 +1,126 @@
+@import "../../css/variables.module";
+
+.excalidraw {
+  .OverwriteConfirm {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 0.75rem;
+    isolation: isolate;
+
+    h3 {
+      margin: 0;
+
+      font-weight: 700;
+      font-size: 1.3125rem;
+      line-height: 130%;
+      align-self: flex-start;
+
+      color: var(--text-primary-color);
+    }
+
+    &__Description {
+      box-sizing: border-box;
+
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      width: 100%;
+      gap: 1rem;
+
+      @include isMobile {
+        flex-direction: column;
+        text-align: center;
+      }
+
+      padding: 2.5rem;
+
+      background: var(--color-danger-background);
+      border-radius: 0.5rem;
+
+      font-family: "Assistant";
+      font-style: normal;
+      font-weight: 400;
+      font-size: 1rem;
+      line-height: 150%;
+
+      color: var(--color-danger-color);
+
+      &__spacer {
+        flex-grow: 1;
+      }
+
+      &__icon {
+        box-sizing: border-box;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        border-radius: 2.5rem;
+        background: var(--color-danger-icon-background);
+        width: 3.5rem;
+        height: 3.5rem;
+
+        padding: 0.75rem;
+
+        svg {
+          color: var(--color-danger-icon-color);
+          width: 1.5rem;
+          height: 1.5rem;
+        }
+      }
+
+      &.OverwriteConfirm__Description--color-warning {
+        background: var(--color-warning-background);
+        color: var(--color-warning-color);
+
+        .OverwriteConfirm__Description__icon {
+          background: var(--color-warning-icon-background);
+          flex: 0 0 auto;
+
+          svg {
+            color: var(--color-warning-icon-color);
+          }
+        }
+      }
+    }
+
+    &__Actions {
+      display: flex;
+      flex-direction: row;
+      align-items: stretch;
+      justify-items: stretch;
+      justify-content: center;
+      gap: 1.5rem;
+
+      @include isMobile {
+        flex-direction: column;
+      }
+
+      &__Action {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        padding: 1.5rem;
+        gap: 0.75rem;
+        flex-basis: 50%;
+        flex-grow: 0;
+
+        &__content {
+          height: 100%;
+          font-size: 0.875rem;
+          text-align: center;
+        }
+
+        h4 {
+          font-weight: 700;
+          font-size: 1.125rem;
+          line-height: 130%;
+
+          margin: 0;
+
+          color: var(--text-primary-color);
+        }
+      }
+    }
+  }
+}

+ 76 - 0
src/components/OverwriteConfirm/OverwriteConfirm.tsx

@@ -0,0 +1,76 @@
+import React from "react";
+import { useAtom } from "jotai";
+
+import { useTunnels } from "../../context/tunnels";
+import { jotaiScope } from "../../jotai";
+import { Dialog } from "../Dialog";
+import { withInternalFallback } from "../hoc/withInternalFallback";
+import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
+
+import { FilledButton } from "../FilledButton";
+import { alertTriangleIcon } from "../icons";
+import { Actions, Action } from "./OverwriteConfirmActions";
+import "./OverwriteConfirm.scss";
+
+export type OverwriteConfirmDialogProps = {
+  children: React.ReactNode;
+};
+
+const OverwriteConfirmDialog = Object.assign(
+  withInternalFallback(
+    "OverwriteConfirmDialog",
+    ({ children }: OverwriteConfirmDialogProps) => {
+      const { OverwriteConfirmDialogTunnel } = useTunnels();
+      const [overwriteConfirmState, setState] = useAtom(
+        overwriteConfirmStateAtom,
+        jotaiScope,
+      );
+
+      if (!overwriteConfirmState.active) {
+        return null;
+      }
+
+      const handleClose = () => {
+        overwriteConfirmState.onClose();
+        setState((state) => ({ ...state, active: false }));
+      };
+
+      const handleConfirm = () => {
+        overwriteConfirmState.onConfirm();
+        setState((state) => ({ ...state, active: false }));
+      };
+
+      return (
+        <OverwriteConfirmDialogTunnel.In>
+          <Dialog onCloseRequest={handleClose} title={false} size={916}>
+            <div className="OverwriteConfirm">
+              <h3>{overwriteConfirmState.title}</h3>
+              <div
+                className={`OverwriteConfirm__Description OverwriteConfirm__Description--color-${overwriteConfirmState.color}`}
+              >
+                <div className="OverwriteConfirm__Description__icon">
+                  {alertTriangleIcon}
+                </div>
+                <div>{overwriteConfirmState.description}</div>
+                <div className="OverwriteConfirm__Description__spacer"></div>
+                <FilledButton
+                  color={overwriteConfirmState.color}
+                  size="large"
+                  label={overwriteConfirmState.actionLabel}
+                  onClick={handleConfirm}
+                />
+              </div>
+              <Actions>{children}</Actions>
+            </div>
+          </Dialog>
+        </OverwriteConfirmDialogTunnel.In>
+      );
+    },
+  ),
+  {
+    Actions,
+    Action,
+  },
+);
+
+export { OverwriteConfirmDialog };

+ 85 - 0
src/components/OverwriteConfirm/OverwriteConfirmActions.tsx

@@ -0,0 +1,85 @@
+import React from "react";
+import { FilledButton } from "../FilledButton";
+import { useExcalidrawActionManager, useExcalidrawSetAppState } from "../App";
+import { actionSaveFileToDisk } from "../../actions";
+import { useI18n } from "../../i18n";
+import { actionChangeExportEmbedScene } from "../../actions/actionExport";
+
+export type ActionProps = {
+  title: string;
+  children: React.ReactNode;
+  actionLabel: string;
+  onClick: () => void;
+};
+
+export const Action = ({
+  title,
+  children,
+  actionLabel,
+  onClick,
+}: ActionProps) => {
+  return (
+    <div className="OverwriteConfirm__Actions__Action">
+      <h4>{title}</h4>
+      <div className="OverwriteConfirm__Actions__Action__content">
+        {children}
+      </div>
+      <FilledButton
+        variant="outlined"
+        color="muted"
+        label={actionLabel}
+        size="large"
+        fullWidth
+        onClick={onClick}
+      />
+    </div>
+  );
+};
+
+export const ExportToImage = () => {
+  const { t } = useI18n();
+  const actionManager = useExcalidrawActionManager();
+  const setAppState = useExcalidrawSetAppState();
+
+  return (
+    <Action
+      title={t("overwriteConfirm.action.exportToImage.title")}
+      actionLabel={t("overwriteConfirm.action.exportToImage.button")}
+      onClick={() => {
+        actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
+        setAppState({ openDialog: "imageExport" });
+      }}
+    >
+      {t("overwriteConfirm.action.exportToImage.description")}
+    </Action>
+  );
+};
+
+export const SaveToDisk = () => {
+  const { t } = useI18n();
+  const actionManager = useExcalidrawActionManager();
+
+  return (
+    <Action
+      title={t("overwriteConfirm.action.saveToDisk.title")}
+      actionLabel={t("overwriteConfirm.action.saveToDisk.button")}
+      onClick={() => {
+        actionManager.executeAction(actionSaveFileToDisk, "ui");
+      }}
+    >
+      {t("overwriteConfirm.action.saveToDisk.description")}
+    </Action>
+  );
+};
+
+const Actions = Object.assign(
+  ({ children }: { children: React.ReactNode }) => {
+    return <div className="OverwriteConfirm__Actions">{children}</div>;
+  },
+  {
+    ExportToImage,
+    SaveToDisk,
+  },
+);
+
+export { Actions };

+ 46 - 0
src/components/OverwriteConfirm/OverwriteConfirmState.ts

@@ -0,0 +1,46 @@
+import { atom } from "jotai";
+import { jotaiStore } from "../../jotai";
+import React from "react";
+
+export type OverwriteConfirmState =
+  | {
+      active: true;
+      title: string;
+      description: React.ReactNode;
+      actionLabel: string;
+      color: "danger" | "warning";
+
+      onClose: () => void;
+      onConfirm: () => void;
+      onReject: () => void;
+    }
+  | { active: false };
+
+export const overwriteConfirmStateAtom = atom<OverwriteConfirmState>({
+  active: false,
+});
+
+export async function openConfirmModal({
+  title,
+  description,
+  actionLabel,
+  color,
+}: {
+  title: string;
+  description: React.ReactNode;
+  actionLabel: string;
+  color: "danger" | "warning";
+}) {
+  return new Promise<boolean>((resolve) => {
+    jotaiStore.set(overwriteConfirmStateAtom, {
+      active: true,
+      onConfirm: () => resolve(true),
+      onClose: () => resolve(false),
+      onReject: () => resolve(false),
+      title,
+      description,
+      actionLabel,
+      color,
+    });
+  });
+}

+ 91 - 0
src/components/ShareableLinkDialog.scss

@@ -0,0 +1,91 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  .ShareableLinkDialog {
+    display: flex;
+    flex-direction: column;
+    gap: 1.5rem;
+
+    color: var(--text-primary-color);
+
+    ::selection {
+      background: var(--color-primary-light-darker);
+    }
+
+    h3 {
+      font-family: "Assistant";
+      font-weight: 700;
+      font-size: 1.313rem;
+      line-height: 130%;
+
+      margin: 0;
+    }
+
+    &__popover {
+      @keyframes RoomDialog__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-lighter);
+      color: var(--color-success);
+
+      & > svg {
+        width: 0.875rem;
+        height: 0.875rem;
+      }
+
+      transform-origin: var(--radix-popover-content-transform-origin);
+      animation: RoomDialog__popover__scaleIn 150ms ease-out;
+    }
+
+    &__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;
+      }
+    }
+  }
+}

+ 91 - 0
src/components/ShareableLinkDialog.tsx

@@ -0,0 +1,91 @@
+import { useRef, useState } from "react";
+import * as Popover from "@radix-ui/react-popover";
+
+import { copyTextToSystemClipboard } from "../clipboard";
+import { useI18n } from "../i18n";
+
+import { Dialog } from "./Dialog";
+import { TextField } from "./TextField";
+import { FilledButton } from "./FilledButton";
+import { copyIcon, tablerCheckIcon } from "./icons";
+
+import "./ShareableLinkDialog.scss";
+
+export type ShareableLinkDialogProps = {
+  link: string;
+
+  onCloseRequest: () => void;
+  setErrorMessage: (error: string) => void;
+};
+
+export const ShareableLinkDialog = ({
+  link,
+  onCloseRequest,
+  setErrorMessage,
+}: ShareableLinkDialogProps) => {
+  const { t } = useI18n();
+  const [justCopied, setJustCopied] = useState(false);
+  const timerRef = useRef<number>(0);
+  const ref = useRef<HTMLInputElement>(null);
+
+  const copyRoomLink = async () => {
+    try {
+      await copyTextToSystemClipboard(link);
+
+      setJustCopied(true);
+
+      if (timerRef.current) {
+        window.clearTimeout(timerRef.current);
+      }
+
+      timerRef.current = window.setTimeout(() => {
+        setJustCopied(false);
+      }, 3000);
+    } catch (error: any) {
+      setErrorMessage(error.message);
+    }
+
+    ref.current?.select();
+  };
+
+  return (
+    <Dialog onCloseRequest={onCloseRequest} title={false} size="small">
+      <div className="ShareableLinkDialog">
+        <h3>Shareable link</h3>
+        <div className="ShareableLinkDialog__linkRow">
+          <TextField
+            ref={ref}
+            label="Link"
+            readonly
+            fullWidth
+            value={link}
+            selectOnRender
+          />
+          <Popover.Root open={justCopied}>
+            <Popover.Trigger asChild>
+              <FilledButton
+                size="large"
+                label="Copy link"
+                startIcon={copyIcon}
+                onClick={copyRoomLink}
+              />
+            </Popover.Trigger>
+            <Popover.Content
+              onOpenAutoFocus={(event) => event.preventDefault()}
+              onCloseAutoFocus={(event) => event.preventDefault()}
+              className="ShareableLinkDialog__popover"
+              side="top"
+              align="end"
+              sideOffset={5.5}
+            >
+              {tablerCheckIcon} copied
+            </Popover.Content>
+          </Popover.Root>
+        </div>
+        <div className="ShareableLinkDialog__description">
+          🔒 {t("alerts.uploadedSecurly")}
+        </div>
+      </div>
+    </Dialog>
+  );
+};

+ 24 - 2
src/components/TextField.tsx

@@ -1,4 +1,10 @@
-import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
+import {
+  forwardRef,
+  useRef,
+  useImperativeHandle,
+  KeyboardEvent,
+  useLayoutEffect,
+} from "react";
 import clsx from "clsx";
 
 import "./TextField.scss";
@@ -12,6 +18,7 @@ export type TextFieldProps = {
 
   readonly?: boolean;
   fullWidth?: boolean;
+  selectOnRender?: boolean;
 
   label?: string;
   placeholder?: string;
@@ -19,13 +26,28 @@ export type TextFieldProps = {
 
 export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
   (
-    { value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
+    {
+      value,
+      onChange,
+      label,
+      fullWidth,
+      placeholder,
+      readonly,
+      selectOnRender,
+      onKeyDown,
+    },
     ref,
   ) => {
     const innerRef = useRef<HTMLInputElement | null>(null);
 
     useImperativeHandle(ref, () => innerRef.current!);
 
+    useLayoutEffect(() => {
+      if (selectOnRender) {
+        innerRef.current?.select();
+      }
+    }, [selectOnRender]);
+
     return (
       <div
         className={clsx("ExcTextField", {

+ 1 - 1
src/components/Tooltip.scss

@@ -6,7 +6,7 @@
     Roboto, Helvetica, Arial, sans-serif;
   font-family: var(--ui-font);
   position: fixed;
-  z-index: 1000;
+  z-index: var(--zIndex-popup);
 
   padding: 8px;
   border-radius: 6px;

+ 10 - 0
src/components/icons.tsx

@@ -1608,6 +1608,16 @@ export const tablerCheckIcon = createIcon(
   tablerIconProps,
 );
 
+export const alertTriangleIcon = createIcon(
+  <>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none" />
+    <path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z" />
+    <path d="M12 9v4" />
+    <path d="M12 17h.01" />
+  </>,
+  tablerIconProps,
+);
+
 export const eyeDropperIcon = createIcon(
   <g strokeWidth={1.25}>
     <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>

+ 29 - 2
src/components/main-menu/DefaultItems.tsx

@@ -1,6 +1,10 @@
 import { getShortcutFromShortcutName } from "../../actions/shortcuts";
 import { useI18n } from "../../i18n";
-import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
+import {
+  useExcalidrawSetAppState,
+  useExcalidrawActionManager,
+  useExcalidrawElements,
+} from "../App";
 import {
   ExportIcon,
   ExportImageIcon,
@@ -29,19 +33,42 @@ import { useSetAtom } from "jotai";
 import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
 import { jotaiScope } from "../../jotai";
 import { useUIAppState } from "../../context/ui-appState";
+import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
+import Trans from "../Trans";
 
 export const LoadScene = () => {
   const { t } = useI18n();
   const actionManager = useExcalidrawActionManager();
+  const elements = useExcalidrawElements();
 
   if (!actionManager.isActionEnabled(actionLoadScene)) {
     return null;
   }
 
+  const handleSelect = async () => {
+    if (
+      !elements.length ||
+      (await openConfirmModal({
+        title: t("overwriteConfirm.modal.loadFromFile.title"),
+        actionLabel: t("overwriteConfirm.modal.loadFromFile.button"),
+        color: "warning",
+        description: (
+          <Trans
+            i18nKey="overwriteConfirm.modal.loadFromFile.description"
+            bold={(text) => <strong>{text}</strong>}
+            br={() => <br />}
+          />
+        ),
+      }))
+    ) {
+      actionManager.executeAction(actionLoadScene);
+    }
+  };
+
   return (
     <DropdownMenuItem
       icon={LoadIcon}
-      onSelect={() => actionManager.executeAction(actionLoadScene)}
+      onSelect={handleSelect}
       data-testid="load-button"
       shortcut={getShortcutFromShortcutName("loadScene")}
       aria-label={t("buttons.load")}

+ 2 - 0
src/context/tunnels.ts

@@ -12,6 +12,7 @@ type TunnelsContextValue = {
   FooterCenterTunnel: Tunnel;
   DefaultSidebarTriggerTunnel: Tunnel;
   DefaultSidebarTabTriggersTunnel: Tunnel;
+  OverwriteConfirmDialogTunnel: Tunnel;
   jotaiScope: symbol;
 };
 
@@ -30,6 +31,7 @@ export const useInitializeTunnels = () => {
       FooterCenterTunnel: tunnel(),
       DefaultSidebarTriggerTunnel: tunnel(),
       DefaultSidebarTabTriggersTunnel: tunnel(),
+      OverwriteConfirmDialogTunnel: tunnel(),
       jotaiScope: Symbol(),
     };
   }, []);

+ 4 - 0
src/css/styles.scss

@@ -5,6 +5,10 @@
   --zIndex-canvas: 1;
   --zIndex-wysiwyg: 2;
   --zIndex-layerUI: 3;
+
+  --zIndex-modal: 1000;
+  --zIndex-popup: 1001;
+  --zIndex-toast: 999999;
 }
 
 .excalidraw {

+ 45 - 0
src/css/theme.scss

@@ -99,9 +99,33 @@
   --color-gray-100: #121212;
 
   --color-warning: #fceeca;
+  --color-warning-dark: #f5c354;
+  --color-warning-darker: #f3ab2c;
+  --color-warning-darkest: #ec8b14;
   --color-text-warning: var(--text-primary-color);
 
   --color-danger: #db6965;
+  --color-danger-dark: #db6965;
+  --color-danger-darker: #d65550;
+  --color-danger-darkest: #d1413c;
+  --color-danger-text: black;
+
+  --color-danger-background: #fff0f0;
+  --color-danger-icon-background: #ffdad6;
+  --color-danger-color: #700000;
+  --color-danger-icon-color: #700000;
+
+  --color-warning-background: var(--color-warning);
+  --color-warning-icon-background: var(--color-warning-dark);
+  --color-warning-color: var(--text-primary-color);
+  --color-warning-icon-color: var(--text-primary-color);
+
+  --color-muted: var(--color-gray-30);
+  --color-muted-darker: var(--color-gray-60);
+  --color-muted-darkest: var(--color-gray-100);
+  --color-muted-background: var(--color-gray-80);
+  --color-muted-background-darker: var(--color-gray-100);
+
   --color-promo: #e70078;
   --color-success: #268029;
   --color-success-lighter: #cafccc;
@@ -177,6 +201,27 @@
     --color-text-warning: var(--color-gray-80);
 
     --color-danger: #ffa8a5;
+    --color-danger-dark: #672120;
+    --color-danger-darker: #8f2625;
+    --color-danger-darkest: #ac2b29;
+    --color-danger-text: #fbcbcc;
+
+    --color-danger-background: #fbcbcc;
+    --color-danger-icon-background: #672120;
+    --color-danger-color: #261919;
+    --color-danger-icon-color: #fbcbcc;
+
+    --color-warning-background: var(--color-warning);
+    --color-warning-icon-background: var(--color-warning-dark);
+    --color-warning-color: var(--color-gray-80);
+    --color-warning-icon-color: var(--color-gray-80);
+
+    --color-muted: var(--color-gray-80);
+    --color-muted-darker: var(--color-gray-60);
+    --color-muted-darkest: var(--color-gray-20);
+    --color-muted-background: var(--color-gray-40);
+    --color-muted-background-darker: var(--color-gray-20);
+
     --color-promo: #d297ff;
   }
 }

+ 1 - 1
src/excalidraw-app/components/ExportToExcalidrawPlus.tsx

@@ -16,7 +16,7 @@ import { MIME_TYPES } from "../../constants";
 import { trackEvent } from "../../analytics";
 import { getFrame } from "../../utils";
 
-const exportToExcalidrawPlus = async (
+export const exportToExcalidrawPlus = async (
   elements: readonly NonDeletedExcalidrawElement[],
   appState: Partial<AppState>,
   files: BinaryFiles,

+ 14 - 6
src/excalidraw-app/data/index.ts

@@ -282,11 +282,15 @@ export const loadScene = async (
   };
 };
 
+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(
@@ -327,14 +331,18 @@ export const exportToBackend = async (
         files: filesToUpload,
       });
 
-      window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
+      return { url: urlString, errorMessage: null };
     } else if (json.error_class === "RequestTooLargeError") {
-      window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
-    } else {
-      window.alert(t("alerts.couldNotCreateShareableLink"));
+      return {
+        url: null,
+        errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
+      };
     }
+
+    return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
   } catch (error: any) {
     console.error(error);
-    window.alert(t("alerts.couldNotCreateShareableLink"));
+
+    return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
   }
 };

+ 67 - 9
src/excalidraw-app/index.tsx

@@ -69,7 +69,10 @@ import {
 } from "./data/localStorage";
 import CustomStats from "./CustomStats";
 import { restore, restoreAppState, RestoredDataState } from "../data/restore";
-import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
+import {
+  ExportToExcalidrawPlus,
+  exportToExcalidrawPlus,
+} from "./components/ExportToExcalidrawPlus";
 import { updateStaleImageStatuses } from "./data/FileManager";
 import { newElementWith } from "../element/mutateElement";
 import { isInitializedImageElement } from "../element/typeChecks";
@@ -88,6 +91,10 @@ import { appJotaiStore } from "./app-jotai";
 
 import "./index.scss";
 import { ResolutionType } from "../utility-types";
+import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
+import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
+import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
+import Trans from "../components/Trans";
 
 polyfill();
 
@@ -98,6 +105,19 @@ languageDetector.init({
   languageUtils: {},
 });
 
+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;
@@ -129,7 +149,7 @@ const initializeScene = async (opts: {
       // don't prompt for collab scenes because we don't override local storage
       roomLinkData ||
       // otherwise, prompt whether user wants to override current scene
-      window.confirm(t("alerts.loadSceneOverridePrompt"))
+      (await openConfirmModal(shareableLinkConfirmDialog))
     ) {
       if (jsonBackendMatch) {
         scene = await loadScene(
@@ -168,7 +188,7 @@ const initializeScene = async (opts: {
       const data = await loadFromBlob(await request.blob(), null, null);
       if (
         !scene.elements.length ||
-        window.confirm(t("alerts.loadSceneOverridePrompt"))
+        (await openConfirmModal(shareableLinkConfirmDialog))
       ) {
         return { scene: data, isExternalScene };
       }
@@ -554,6 +574,10 @@ const ExcalidrawWrapper = () => {
     }
   };
 
+  const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
+    null,
+  );
+
   const onExportToBackend = async (
     exportedElements: readonly NonDeletedExcalidrawElement[],
     appState: Partial<AppState>,
@@ -565,7 +589,7 @@ const ExcalidrawWrapper = () => {
     }
     if (canvas) {
       try {
-        await exportToBackend(
+        const { url, errorMessage } = await exportToBackend(
           exportedElements,
           {
             ...appState,
@@ -575,6 +599,14 @@ const ExcalidrawWrapper = () => {
           },
           files,
         );
+
+        if (errorMessage) {
+          setErrorMessage(errorMessage);
+        }
+
+        if (url) {
+          setLatestShareableLink(url);
+        }
       } catch (error: any) {
         if (error.name !== "AbortError") {
           const { width, height } = canvas;
@@ -674,21 +706,47 @@ const ExcalidrawWrapper = () => {
           setCollabDialogShown={setCollabDialogShown}
           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(),
+                );
+              }}
+            >
+              {t("overwriteConfirm.action.excalidrawPlus.description")}
+            </OverwriteConfirmDialog.Action>
+          )}
+        </OverwriteConfirmDialog>
         <AppFooter />
         {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} />
         )}
+        {errorMessage && (
+          <ErrorDialog onClose={() => setErrorMessage("")}>
+            {errorMessage}
+          </ErrorDialog>
+        )}
       </Excalidraw>
-      {errorMessage && (
-        <ErrorDialog onClose={() => setErrorMessage("")}>
-          {errorMessage}
-        </ErrorDialog>
-      )}
     </div>
   );
 };

+ 31 - 0
src/locales/en.json

@@ -449,5 +449,36 @@
     "shades": "Shades",
     "hexCode": "Hex code",
     "noShades": "No shades available for this color"
+  },
+  "overwriteConfirm": {
+    "action": {
+      "exportToImage": {
+        "title": "Export as image",
+        "button": "Export as image",
+        "description": "Export the scene data as an image from which you can import later."
+      },
+      "saveToDisk": {
+        "title": "Save to disk",
+        "button": "Save to disk",
+        "description": "Export the scene data to a file from which you can import later."
+      },
+      "excalidrawPlus": {
+        "title": "Excalidraw+",
+        "button": "Export to Excalidraw+",
+        "description": "Save the scene to your Excalidraw+ workspace."
+      }
+    },
+    "modal": {
+      "loadFromFile": {
+        "title": "Load from file",
+        "button": "Load from file",
+        "description": "Loading from a file will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first using one of the options below."
+      },
+      "shareableLink": {
+        "title": "Load from link",
+        "button": "Replace my content",
+        "description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
+      }
+    }
   }
 }