feat: overwrite confirmation dialogs (#6658)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Are 2023-06-19 17:08:12 +02:00 committed by GitHub
parent 6d56634289
commit 7558a4e2be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 918 additions and 69 deletions

View File

@ -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}
>

View File

@ -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;

View File

@ -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}

View File

@ -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 && (

View File

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

View File

@ -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);
}
}
}
}
}

View File

@ -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 };

View File

@ -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 };

View File

@ -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,
});
});
}

View File

@ -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;
}
}
}
}

View File

@ -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>
);
};

View File

@ -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", {

View File

@ -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;

View File

@ -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>

View File

@ -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")}

View File

@ -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(),
};
}, []);

View File

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

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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") };
}
};

View File

@ -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>
);
};

View File

@ -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."
}
}
}
}