diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 161d1805..34e6cf87 100644 --- a/src/components/Dialog.tsx +++ b/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(); const [lastActiveElement] = useState(document.activeElement); @@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => { diff --git a/src/components/FilledButton.scss b/src/components/FilledButton.scss index d742e22e..e171ff8d 100644 --- a/src/components/FilledButton.scss +++ b/src/components/FilledButton.scss @@ -2,20 +2,140 @@ .excalidraw { .ExcButton { - &--color-primary { - color: var(--input-bg-color); + --text-color: transparent; + --border-color: transparent; + --back-color: transparent; - --accent-color: var(--color-primary); - --accent-color-hover: var(--color-primary-darker); - --accent-color-active: var(--color-primary-darkest); + color: var(--text-color); + background-color: var(--back-color); + border-color: var(--border-color); + + &--color-primary { + &.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); + } + } + + &.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); - --accent-color: var(--color-danger); - --accent-color-hover: #d65550; - --accent-color-active: #d1413c; + &: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); + + &: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; diff --git a/src/components/FilledButton.tsx b/src/components/FilledButton.tsx index 0db72421..3f844cf3 100644 --- a/src/components/FilledButton.tsx +++ b/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( variant = "filled", color = "primary", size = "medium", + fullWidth, className, }, ref, @@ -42,6 +44,7 @@ export const FilledButton = forwardRef( `ExcButton--color-${color}`, `ExcButton--variant-${variant}`, `ExcButton--size-${size}`, + { "ExcButton--fullWidth": fullWidth }, className, )} onClick={onClick} diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 2c868453..04cba862 100644 --- a/src/components/LayerUI.tsx +++ b/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 ( + + + + + ); +}; + const LayerUI = ({ actionManager, appState, @@ -343,6 +353,7 @@ const LayerUI = ({ > {t("toolBar.library")} + {/* ------------------------------------------------------------------ */} {appState.isLoading && } @@ -374,6 +385,7 @@ const LayerUI = ({ /> )} + {renderImageExportDialog()} {renderJSONExportDialog()} {appState.pasteDialog.shown && ( diff --git a/src/components/Modal.scss b/src/components/Modal.scss index 8bf50caa..ad2d0c56 100644 --- a/src/components/Modal.scss +++ b/src/components/Modal.scss @@ -3,7 +3,7 @@ .excalidraw { &.excalidraw-modal-container { position: absolute; - z-index: 10; + z-index: var(--zIndex-modal); } .Modal { diff --git a/src/components/OverwriteConfirm/OverwriteConfirm.scss b/src/components/OverwriteConfirm/OverwriteConfirm.scss new file mode 100644 index 00000000..23dd4f41 --- /dev/null +++ b/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); + } + } + } + } +} diff --git a/src/components/OverwriteConfirm/OverwriteConfirm.tsx b/src/components/OverwriteConfirm/OverwriteConfirm.tsx new file mode 100644 index 00000000..9a674cd3 --- /dev/null +++ b/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 ( + + +
+

{overwriteConfirmState.title}

+
+
+ {alertTriangleIcon} +
+
{overwriteConfirmState.description}
+
+ +
+ {children} +
+
+
+ ); + }, + ), + { + Actions, + Action, + }, +); + +export { OverwriteConfirmDialog }; diff --git a/src/components/OverwriteConfirm/OverwriteConfirmActions.tsx b/src/components/OverwriteConfirm/OverwriteConfirmActions.tsx new file mode 100644 index 00000000..b5bec84c --- /dev/null +++ b/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 ( +
+

{title}

+
+ {children} +
+ +
+ ); +}; + +export const ExportToImage = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + const setAppState = useExcalidrawSetAppState(); + + return ( + { + actionManager.executeAction(actionChangeExportEmbedScene, "ui", true); + setAppState({ openDialog: "imageExport" }); + }} + > + {t("overwriteConfirm.action.exportToImage.description")} + + ); +}; + +export const SaveToDisk = () => { + const { t } = useI18n(); + const actionManager = useExcalidrawActionManager(); + + return ( + { + actionManager.executeAction(actionSaveFileToDisk, "ui"); + }} + > + {t("overwriteConfirm.action.saveToDisk.description")} + + ); +}; + +const Actions = Object.assign( + ({ children }: { children: React.ReactNode }) => { + return
{children}
; + }, + { + ExportToImage, + SaveToDisk, + }, +); + +export { Actions }; diff --git a/src/components/OverwriteConfirm/OverwriteConfirmState.ts b/src/components/OverwriteConfirm/OverwriteConfirmState.ts new file mode 100644 index 00000000..99060ccf --- /dev/null +++ b/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({ + active: false, +}); + +export async function openConfirmModal({ + title, + description, + actionLabel, + color, +}: { + title: string; + description: React.ReactNode; + actionLabel: string; + color: "danger" | "warning"; +}) { + return new Promise((resolve) => { + jotaiStore.set(overwriteConfirmStateAtom, { + active: true, + onConfirm: () => resolve(true), + onClose: () => resolve(false), + onReject: () => resolve(false), + title, + description, + actionLabel, + color, + }); + }); +} diff --git a/src/components/ShareableLinkDialog.scss b/src/components/ShareableLinkDialog.scss new file mode 100644 index 00000000..595acf7d --- /dev/null +++ b/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; + } + } + } +} diff --git a/src/components/ShareableLinkDialog.tsx b/src/components/ShareableLinkDialog.tsx new file mode 100644 index 00000000..7a53a4a8 --- /dev/null +++ b/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(0); + const ref = useRef(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 ( + +
+

Shareable link

+
+ + + + + + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + className="ShareableLinkDialog__popover" + side="top" + align="end" + sideOffset={5.5} + > + {tablerCheckIcon} copied + + +
+
+ 🔒 {t("alerts.uploadedSecurly")} +
+
+
+ ); +}; diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 7f7a41fd..0f87bdb5 100644 --- a/src/components/TextField.tsx +++ b/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( ( - { value, onChange, label, fullWidth, placeholder, readonly, onKeyDown }, + { + value, + onChange, + label, + fullWidth, + placeholder, + readonly, + selectOnRender, + onKeyDown, + }, ref, ) => { const innerRef = useRef(null); useImperativeHandle(ref, () => innerRef.current!); + useLayoutEffect(() => { + if (selectOnRender) { + innerRef.current?.select(); + } + }, [selectOnRender]); + return (
+ + + + + , + tablerIconProps, +); + export const eyeDropperIcon = createIcon( diff --git a/src/components/main-menu/DefaultItems.tsx b/src/components/main-menu/DefaultItems.tsx index 100af104..919443d1 100644 --- a/src/components/main-menu/DefaultItems.tsx +++ b/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: ( + {text}} + br={() =>
} + /> + ), + })) + ) { + actionManager.executeAction(actionLoadScene); + } + }; + return ( actionManager.executeAction(actionLoadScene)} + onSelect={handleSelect} data-testid="load-button" shortcut={getShortcutFromShortcutName("loadScene")} aria-label={t("buttons.load")} diff --git a/src/context/tunnels.ts b/src/context/tunnels.ts index c5eaef9b..fa807a60 100644 --- a/src/context/tunnels.ts +++ b/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(), }; }, []); diff --git a/src/css/styles.scss b/src/css/styles.scss index 3fc2ff56..bdd2e315 100644 --- a/src/css/styles.scss +++ b/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 { diff --git a/src/css/theme.scss b/src/css/theme.scss index 92b5989a..4e7af4b9 100644 --- a/src/css/theme.scss +++ b/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; } } diff --git a/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx index 86cf9cc4..8e6342d3 100644 --- a/src/excalidraw-app/components/ExportToExcalidrawPlus.tsx +++ b/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, files: BinaryFiles, diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts index 0e3cc690..c85eb27c 100644 --- a/src/excalidraw-app/data/index.ts +++ b/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, files: BinaryFiles, -) => { +): Promise => { 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") }; } }; diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 3952ac24..860437f3 100644 --- a/src/excalidraw-app/index.tsx +++ b/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: ( + {text}} + 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( + null, + ); + const onExportToBackend = async ( exportedElements: readonly NonDeletedExcalidrawElement[], appState: Partial, @@ -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} /> + + + + {excalidrawAPI && ( + { + exportToExcalidrawPlus( + excalidrawAPI.getSceneElements(), + excalidrawAPI.getAppState(), + excalidrawAPI.getFiles(), + ); + }} + > + {t("overwriteConfirm.action.excalidrawPlus.description")} + + )} + {isCollaborating && isOffline && (
{t("alerts.collabOfflineWarning")}
)} + {latestShareableLink && ( + setLatestShareableLink(null)} + setErrorMessage={setErrorMessage} + /> + )} {excalidrawAPI && !isCollabDisabled && ( )} + {errorMessage && ( + setErrorMessage("")}> + {errorMessage} + + )} - {errorMessage && ( - setErrorMessage("")}> - {errorMessage} - - )}
); }; diff --git a/src/locales/en.json b/src/locales/en.json index b73f7999..7092e9be 100644 --- a/src/locales/en.json +++ b/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 replace your existing content.

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 replace your existing content.

You can back up your drawing first by using one of the options below." + } + } } }