瀏覽代碼

feat: update design of ImageExportDialog (#6614)

Co-authored-by: dwelle <[email protected]>
Are 2 年之前
父節點
當前提交
08563e7d7b

+ 2 - 1
src/actions/actionExport.tsx

@@ -26,7 +26,7 @@ export const actionChangeProjectName = register({
   perform: (_elements, appState, value) => {
     return { appState: { ...appState, name: value }, commitToHistory: false };
   },
-  PanelComponent: ({ appState, updateData, appProps }) => (
+  PanelComponent: ({ appState, updateData, appProps, data }) => (
     <ProjectName
       label={t("labels.fileTitle")}
       value={appState.name || "Unnamed"}
@@ -34,6 +34,7 @@ export const actionChangeProjectName = register({
       isNameEditable={
         typeof appProps.name === "undefined" && !appState.viewModeEnabled
       }
+      ignoreFocus={data?.ignoreFocus ?? false}
     />
   ),
 });

+ 5 - 2
src/actions/manager.tsx

@@ -118,10 +118,13 @@ export class ActionManager {
     return true;
   }
 
-  executeAction(action: Action, source: ActionSource = "api") {
+  executeAction(
+    action: Action,
+    source: ActionSource = "api",
+    value: any = null,
+  ) {
     const elements = this.getElementsIncludingDeleted();
     const appState = this.getAppState();
-    const value = null;
 
     trackAction(action, source, appState, elements, this.app, value);
 

+ 1 - 1
src/components/ConfirmDialog.tsx

@@ -31,7 +31,7 @@ const ConfirmDialog = (props: Props) => {
   return (
     <Dialog
       onCloseRequest={onCancel}
-      small={true}
+      size="small"
       {...rest}
       className={`confirm-dialog ${className}`}
     >

+ 29 - 0
src/components/Dialog.scss

@@ -14,4 +14,33 @@
     padding: 0 0 0.75rem;
     margin-bottom: 1.5rem;
   }
+
+  .Dialog__close {
+    color: var(--color-gray-40);
+    margin: 0;
+    position: absolute;
+    top: 0.75rem;
+    right: 0.5rem;
+    border: 0;
+    background-color: transparent;
+    line-height: 0;
+    cursor: pointer;
+
+    &:hover {
+      color: var(--color-gray-60);
+    }
+    &:active {
+      color: var(--color-gray-40);
+    }
+
+    @include isMobile {
+      top: 1.25rem;
+      right: 1.25rem;
+    }
+
+    svg {
+      width: 1.5rem;
+      height: 1.5rem;
+    }
+  }
 }

+ 19 - 14
src/components/Dialog.tsx

@@ -21,9 +21,9 @@ import { jotaiScope } from "../jotai";
 export interface DialogProps {
   children: React.ReactNode;
   className?: string;
-  small?: boolean;
+  size?: "small" | "regular" | "wide";
   onCloseRequest(): void;
-  title: React.ReactNode;
+  title: React.ReactNode | false;
   autofocus?: boolean;
   theme?: AppState["theme"];
   closeOnClickOutside?: boolean;
@@ -33,6 +33,7 @@ export const Dialog = (props: DialogProps) => {
   const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
   const [lastActiveElement] = useState(document.activeElement);
   const { id } = useExcalidrawContainer();
+  const device = useDevice();
 
   useEffect(() => {
     if (!islandNode) {
@@ -86,23 +87,27 @@ export const Dialog = (props: DialogProps) => {
     <Modal
       className={clsx("Dialog", props.className)}
       labelledBy="dialog-title"
-      maxWidth={props.small ? 550 : 800}
+      maxWidth={
+        props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
+      }
       onCloseRequest={onClose}
       theme={props.theme}
       closeOnClickOutside={props.closeOnClickOutside}
     >
       <Island ref={setIslandNode}>
-        <h2 id={`${id}-dialog-title`} className="Dialog__title">
-          <span className="Dialog__titleContent">{props.title}</span>
-          <button
-            className="Modal__close"
-            onClick={onClose}
-            title={t("buttons.close")}
-            aria-label={t("buttons.close")}
-          >
-            {useDevice().isMobile ? back : CloseIcon}
-          </button>
-        </h2>
+        {props.title && (
+          <h2 id={`${id}-dialog-title`} className="Dialog__title">
+            <span className="Dialog__titleContent">{props.title}</span>
+          </h2>
+        )}
+        <button
+          className="Dialog__close"
+          onClick={onClose}
+          title={t("buttons.close")}
+          aria-label={t("buttons.close")}
+        >
+          {device.isMobile ? back : CloseIcon}
+        </button>
         <div className="Dialog__content">{props.children}</div>
       </Island>
     </Modal>

+ 1 - 1
src/components/ErrorDialog.tsx

@@ -28,7 +28,7 @@ export const ErrorDialog = ({
     <>
       {modalIsShown && (
         <Dialog
-          small
+          size="small"
           onCloseRequest={handleClose}
           title={t("errorDialog.title")}
         >

+ 215 - 0
src/components/ImageExportDialog.scss

@@ -0,0 +1,215 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  --ImageExportModal-preview-border: #d6d6d6;
+
+  &.theme--dark {
+    --ImageExportModal-preview-border: #5c5c5c;
+  }
+
+  .ImageExportModal {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+
+    & h3 {
+      font-family: "Assistant";
+      font-style: normal;
+      font-weight: 700;
+      font-size: 1.313rem;
+      line-height: 130%;
+      padding: 0;
+      margin: 0;
+
+      @include isMobile {
+        display: none;
+      }
+    }
+
+    & > h3 {
+      display: none;
+
+      @include isMobile {
+        display: block;
+      }
+    }
+
+    @include isMobile {
+      flex-direction: column;
+      height: calc(100vh - 5rem);
+    }
+
+    &__preview {
+      box-sizing: border-box;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      height: 360px;
+      width: 55%;
+
+      margin-right: 1.5rem;
+
+      @include isMobile {
+        max-width: unset;
+        margin-right: unset;
+
+        width: 100%;
+        height: unset;
+        flex-grow: 1;
+      }
+
+      &__filename {
+        & > input {
+          margin-top: 1rem;
+        }
+      }
+
+      &__canvas {
+        box-sizing: border-box;
+        width: 100%;
+        height: 100%;
+        display: flex;
+        flex-grow: 1;
+        justify-content: center;
+        align-items: center;
+
+        background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
+          left center;
+
+        border: 1px solid var(--ImageExportModal-preview-border);
+        border-radius: 12px;
+
+        overflow: hidden;
+        padding: 1rem;
+
+        & > canvas {
+          max-width: calc(100% - 2rem);
+          max-height: calc(100% - 2rem);
+
+          filter: none !important;
+
+          @include isMobile {
+            max-height: 100%;
+          }
+        }
+
+        @include isMobile {
+          margin-top: 24px;
+          max-width: unset;
+        }
+      }
+    }
+
+    &__settings {
+      display: flex;
+      flex-direction: column;
+      flex-wrap: wrap;
+      gap: 18px;
+
+      @include isMobile {
+        margin-left: unset;
+        margin-top: 1rem;
+        flex-direction: row;
+        gap: 6px 34px;
+
+        align-content: flex-start;
+      }
+
+      &__setting {
+        display: flex;
+        flex-direction: row;
+        justify-content: space-between;
+        align-items: center;
+
+        @include isMobile {
+          flex-direction: column;
+          align-items: start;
+          justify-content: unset;
+          height: 52px;
+        }
+
+        &__label {
+          display: flex;
+          flex-direction: row;
+          align-items: center;
+
+          font-family: "Assistant";
+          font-weight: 600;
+          font-size: 1rem;
+          line-height: 150%;
+
+          & svg {
+            width: 20px;
+            height: 20px;
+            margin-left: 10px;
+          }
+        }
+
+        &__content {
+          display: flex;
+          height: 100%;
+          align-items: center;
+        }
+      }
+
+      &__buttons {
+        flex-grow: 1;
+        flex-wrap: wrap;
+        display: flex;
+        flex-direction: row;
+        gap: 11px;
+
+        align-items: flex-end;
+        align-content: flex-end;
+
+        @include isMobile {
+          padding-top: 32px;
+          flex-basis: 100%;
+          justify-content: center;
+        }
+
+        &__button {
+          box-sizing: border-box;
+          display: flex;
+          flex-direction: row;
+          justify-content: center;
+          align-items: center;
+          padding: 8px 16px;
+          flex-shrink: 0;
+          width: fit-content;
+          gap: 8px;
+
+          height: 40px;
+          border: 0;
+          border-radius: 8px;
+
+          user-select: none;
+          font-family: "Assistant";
+          font-style: normal;
+          font-weight: 600;
+          font-size: 0.75rem;
+          line-height: 100%;
+          transition: 150ms ease-out;
+          transition-property: background, color;
+
+          background: var(--color-primary);
+          color: var(--color-icon-white);
+
+          &:hover {
+            background: var(--color-primary-darker);
+            color: var(--color-icon-white);
+          }
+
+          &:active {
+            background: var(--color-primary-darkest);
+          }
+
+          & > svg {
+            width: 20px;
+            height: 20px;
+          }
+        }
+      }
+    }
+  }
+}

+ 226 - 124
src/components/ImageExportDialog.tsx

@@ -1,25 +1,39 @@
 import React, { useEffect, useRef, useState } from "react";
+
+import type { ActionManager } from "../actions/manager";
+import type { AppClassProperties, BinaryFiles, UIAppState } from "../types";
+
+import {
+  actionExportWithDarkMode,
+  actionChangeExportBackground,
+  actionChangeExportEmbedScene,
+  actionChangeExportScale,
+  actionChangeProjectName,
+} from "../actions/actionExport";
 import { probablySupportsClipboardBlob } from "../clipboard";
-import { canvasToBlob } from "../data/blob";
-import { NonDeletedExcalidrawElement } from "../element/types";
-import { t } from "../i18n";
-import { getSelectedElements, isSomeElementSelected } from "../scene";
-import { AppClassProperties, BinaryFiles, UIAppState } from "../types";
-import { Dialog } from "./Dialog";
-import { clipboard } from "./icons";
-import Stack from "./Stack";
-import OpenColor from "open-color";
-import { CheckboxItem } from "./CheckboxItem";
 import {
   DEFAULT_EXPORT_PADDING,
   EXPORT_IMAGE_TYPES,
   isFirefox,
+  EXPORT_SCALES,
 } from "../constants";
+
+import { canvasToBlob } from "../data/blob";
 import { nativeFileSystemSupported } from "../data/filesystem";
-import { ActionManager } from "../actions/manager";
+import { NonDeletedExcalidrawElement } from "../element/types";
+import { t } from "../i18n";
+import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { exportToCanvas } from "../packages/utils";
 
-import "./ExportDialog.scss";
+import { copyIcon, downloadIcon, helpIcon } from "./icons";
+import { Button } from "./Button";
+import { Dialog } from "./Dialog";
+import { RadioGroup } from "./RadioGroup";
+import { Switch } from "./Switch";
+import { Tooltip } from "./Tooltip";
+
+import "./ImageExportDialog.scss";
+import { useAppProps } from "./App";
 
 const supportsContextFilters =
   "filter" in document.createElement("canvas").getContext("2d")!;
@@ -36,50 +50,36 @@ export const ErrorCanvasPreview = () => {
   );
 };
 
-export type ExportCB = (
-  elements: readonly NonDeletedExcalidrawElement[],
-  scale?: number,
-) => void;
-
-const ExportButton: React.FC<{
-  color: keyof OpenColor;
-  onClick: () => void;
-  title: string;
-  shade?: number;
-  children?: React.ReactNode;
-}> = ({ children, title, onClick, color, shade = 6 }) => {
-  return (
-    <button
-      className="ExportDialog-imageExportButton"
-      style={{
-        ["--button-color" as any]: OpenColor[color][shade],
-        ["--button-color-darker" as any]: OpenColor[color][shade + 1],
-        ["--button-color-darkest" as any]: OpenColor[color][shade + 2],
-      }}
-      title={title}
-      aria-label={title}
-      onClick={onClick}
-    >
-      {children}
-    </button>
-  );
+type ImageExportModalProps = {
+  appState: UIAppState;
+  elements: readonly NonDeletedExcalidrawElement[];
+  files: BinaryFiles;
+  actionManager: ActionManager;
+  onExportImage: AppClassProperties["onExportImage"];
 };
 
 const ImageExportModal = ({
-  elements,
   appState,
+  elements,
   files,
   actionManager,
   onExportImage,
-}: {
-  appState: UIAppState;
-  elements: readonly NonDeletedExcalidrawElement[];
-  files: BinaryFiles;
-  actionManager: ActionManager;
-  onExportImage: AppClassProperties["onExportImage"];
-}) => {
+}: ImageExportModalProps) => {
+  const appProps = useAppProps();
+  const [projectName, setProjectName] = useState(appState.name);
+
   const someElementIsSelected = isSomeElementSelected(elements, appState);
+
   const [exportSelected, setExportSelected] = useState(someElementIsSelected);
+  const [exportWithBackground, setExportWithBackground] = useState(
+    appState.exportBackground,
+  );
+  const [exportDarkMode, setExportDarkMode] = useState(
+    appState.exportWithDarkMode,
+  );
+  const [embedScene, setEmbedScene] = useState(appState.exportEmbedScene);
+  const [exportScale, setExportScale] = useState(appState.exportScale);
+
   const previewRef = useRef<HTMLDivElement>(null);
   const [renderError, setRenderError] = useState<Error | null>(null);
 
@@ -93,6 +93,7 @@ const ImageExportModal = ({
       return;
     }
     const maxWidth = previewNode.offsetWidth;
+    const maxHeight = previewNode.offsetHeight;
     if (!maxWidth) {
       return;
     }
@@ -101,7 +102,7 @@ const ImageExportModal = ({
       appState,
       files,
       exportPadding: DEFAULT_EXPORT_PADDING,
-      maxWidthOrHeight: maxWidth,
+      maxWidthOrHeight: Math.max(maxWidth, maxHeight),
     })
       .then((canvas) => {
         setRenderError(null);
@@ -118,89 +119,190 @@ const ImageExportModal = ({
   }, [appState, files, exportedElements]);
 
   return (
-    <div className="ExportDialog">
-      <div className="ExportDialog__preview" ref={previewRef}>
-        {renderError && <ErrorCanvasPreview />}
-      </div>
-      {supportsContextFilters &&
-        actionManager.renderAction("exportWithDarkMode")}
-      <div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
-        <div
-          style={{
-            display: "grid",
-            gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))",
-            // dunno why this is needed, but when the items wrap it creates
-            // an overflow
-            overflow: "hidden",
-          }}
-        >
-          {actionManager.renderAction("changeExportBackground")}
-          {someElementIsSelected && (
-            <CheckboxItem
-              checked={exportSelected}
-              onChange={(checked) => setExportSelected(checked)}
-            >
-              {t("labels.onlySelected")}
-            </CheckboxItem>
+    <div className="ImageExportModal">
+      <h3>{t("imageExportDialog.header")}</h3>
+      <div className="ImageExportModal__preview">
+        <div className="ImageExportModal__preview__canvas" ref={previewRef}>
+          {renderError && <ErrorCanvasPreview />}
+        </div>
+        <div className="ImageExportModal__preview__filename">
+          {!nativeFileSystemSupported && (
+            <input
+              type="text"
+              className="TextInput"
+              value={projectName}
+              style={{ width: "30ch" }}
+              disabled={
+                typeof appProps.name !== "undefined" || appState.viewModeEnabled
+              }
+              onChange={(event) => {
+                setProjectName(event.target.value);
+                actionManager.executeAction(
+                  actionChangeProjectName,
+                  "ui",
+                  event.target.value,
+                );
+              }}
+            />
           )}
-          {actionManager.renderAction("changeExportEmbedScene")}
         </div>
       </div>
-      <div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
-        <Stack.Row gap={2}>
-          {actionManager.renderAction("changeExportScale")}
-        </Stack.Row>
-        <p style={{ marginLeft: "1em", userSelect: "none" }}>
-          {t("buttons.scale")}
-        </p>
-      </div>
-      <div
-        style={{
-          display: "flex",
-          alignItems: "center",
-          justifyContent: "center",
-          margin: ".6em 0",
-        }}
-      >
-        {!nativeFileSystemSupported &&
-          actionManager.renderAction("changeProjectName")}
-      </div>
-      <Stack.Row gap={2} justifyContent="center" style={{ margin: "2em 0" }}>
-        <ExportButton
-          color="indigo"
-          title={t("buttons.exportToPng")}
-          aria-label={t("buttons.exportToPng")}
-          onClick={() =>
-            onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
-          }
+      <div className="ImageExportModal__settings">
+        <h3>{t("imageExportDialog.header")}</h3>
+        {someElementIsSelected && (
+          <ExportSetting
+            label={t("imageExportDialog.label.onlySelected")}
+            name="exportOnlySelected"
+          >
+            <Switch
+              name="exportOnlySelected"
+              checked={exportSelected}
+              onChange={(checked) => {
+                setExportSelected(checked);
+              }}
+            />
+          </ExportSetting>
+        )}
+        <ExportSetting
+          label={t("imageExportDialog.label.withBackground")}
+          name="exportBackgroundSwitch"
+        >
+          <Switch
+            name="exportBackgroundSwitch"
+            checked={exportWithBackground}
+            onChange={(checked) => {
+              setExportWithBackground(checked);
+              actionManager.executeAction(
+                actionChangeExportBackground,
+                "ui",
+                checked,
+              );
+            }}
+          />
+        </ExportSetting>
+        {supportsContextFilters && (
+          <ExportSetting
+            label={t("imageExportDialog.label.darkMode")}
+            name="exportDarkModeSwitch"
+          >
+            <Switch
+              name="exportDarkModeSwitch"
+              checked={exportDarkMode}
+              onChange={(checked) => {
+                setExportDarkMode(checked);
+                actionManager.executeAction(
+                  actionExportWithDarkMode,
+                  "ui",
+                  checked,
+                );
+              }}
+            />
+          </ExportSetting>
+        )}
+        <ExportSetting
+          label={t("imageExportDialog.label.embedScene")}
+          tooltip={t("imageExportDialog.tooltip.embedScene")}
+          name="exportEmbedSwitch"
         >
-          PNG
-        </ExportButton>
-        <ExportButton
-          color="red"
-          title={t("buttons.exportToSvg")}
-          aria-label={t("buttons.exportToSvg")}
-          onClick={() =>
-            onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
-          }
+          <Switch
+            name="exportEmbedSwitch"
+            checked={embedScene}
+            onChange={(checked) => {
+              setEmbedScene(checked);
+              actionManager.executeAction(
+                actionChangeExportEmbedScene,
+                "ui",
+                checked,
+              );
+            }}
+          />
+        </ExportSetting>
+        <ExportSetting
+          label={t("imageExportDialog.label.scale")}
+          name="exportScale"
         >
-          SVG
-        </ExportButton>
-        {/* firefox supports clipboard API under a flag,
-            so let's throw and tell people what they can do */}
-        {(probablySupportsClipboardBlob || isFirefox) && (
-          <ExportButton
-            title={t("buttons.copyPngToClipboard")}
-            onClick={() =>
-              onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
+          <RadioGroup
+            name="exportScale"
+            value={exportScale}
+            onChange={(scale) => {
+              setExportScale(scale);
+              actionManager.executeAction(actionChangeExportScale, "ui", scale);
+            }}
+            choices={EXPORT_SCALES.map((scale) => ({
+              value: scale,
+              label: `${scale}\u00d7`,
+            }))}
+          />
+        </ExportSetting>
+
+        <div className="ImageExportModal__settings__buttons">
+          <Button
+            className="ImageExportModal__settings__buttons__button"
+            title={t("imageExportDialog.title.exportToPng")}
+            aria-label={t("imageExportDialog.title.exportToPng")}
+            onSelect={() =>
+              onExportImage(EXPORT_IMAGE_TYPES.png, exportedElements)
+            }
+          >
+            {downloadIcon} {t("imageExportDialog.button.exportToPng")}
+          </Button>
+          <Button
+            className="ImageExportModal__settings__buttons__button"
+            title={t("imageExportDialog.title.exportToSvg")}
+            aria-label={t("imageExportDialog.title.exportToSvg")}
+            onSelect={() =>
+              onExportImage(EXPORT_IMAGE_TYPES.svg, exportedElements)
             }
-            color="gray"
-            shade={7}
           >
-            {clipboard}
-          </ExportButton>
+            {downloadIcon} {t("imageExportDialog.button.exportToSvg")}
+          </Button>
+          {(probablySupportsClipboardBlob || isFirefox) && (
+            <Button
+              className="ImageExportModal__settings__buttons__button"
+              title={t("imageExportDialog.title.copyPngToClipboard")}
+              aria-label={t("imageExportDialog.title.copyPngToClipboard")}
+              onSelect={() =>
+                onExportImage(EXPORT_IMAGE_TYPES.clipboard, exportedElements)
+              }
+            >
+              {copyIcon} {t("imageExportDialog.button.copyPngToClipboard")}
+            </Button>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+type ExportSettingProps = {
+  label: string;
+  children: React.ReactNode;
+  tooltip?: string;
+  name?: string;
+};
+
+const ExportSetting = ({
+  label,
+  children,
+  tooltip,
+  name,
+}: ExportSettingProps) => {
+  return (
+    <div className="ImageExportModal__settings__setting" title={label}>
+      <label
+        htmlFor={name}
+        className="ImageExportModal__settings__setting__label"
+      >
+        {label}
+        {tooltip && (
+          <Tooltip label={tooltip} long={true}>
+            {helpIcon}
+          </Tooltip>
         )}
-      </Stack.Row>
+      </label>
+      <div className="ImageExportModal__settings__setting__content">
+        {children}
+      </div>
     </div>
   );
 };
@@ -225,7 +327,7 @@ export const ImageExportDialog = ({
   }
 
   return (
-    <Dialog onCloseRequest={onCloseRequest} title={t("buttons.exportImage")}>
+    <Dialog onCloseRequest={onCloseRequest} size="wide" title={false}>
       <ImageExportModal
         elements={elements}
         appState={appState}

+ 1 - 1
src/components/LibraryMenuHeaderContent.tsx

@@ -106,7 +106,7 @@ export const LibraryDropdownMenuButton: React.FC<{
         onCloseRequest={() => setPublishLibSuccess(null)}
         title={t("publishSuccessDialog.title")}
         className="publish-library-success"
-        small={true}
+        size="small"
       >
         <p>
           <Trans

+ 14 - 3
src/components/Modal.scss

@@ -24,13 +24,15 @@
   }
 
   .Modal__background {
-    position: absolute;
+    position: fixed;
     top: 0;
     left: 0;
     right: 0;
     bottom: 0;
     z-index: 1;
     background-color: rgba(#121212, 0.2);
+
+    animation: Modal__background__fade-in 0.125s linear forwards;
   }
 
   .Modal__content {
@@ -65,14 +67,23 @@
     }
   }
 
+  @keyframes Modal__background__fade-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+
   @keyframes Modal__content_fade-in {
     from {
       opacity: 0;
-      transform: translateY(10px);
+      transform: scale(0.9);
     }
     to {
       opacity: 1;
-      transform: translateY(0);
+      transform: scale(1);
     }
   }
 

+ 1 - 1
src/components/Modal.tsx

@@ -44,7 +44,7 @@ export const Modal: React.FC<{
       <div
         className="Modal__background"
         onClick={closeOnClickOutside ? props.onCloseRequest : undefined}
-      ></div>
+      />
       <div
         className="Modal__content"
         style={{ "--max-width": `${props.maxWidth}px` }}

+ 1 - 1
src/components/PasteChartDialog.tsx

@@ -106,7 +106,7 @@ export const PasteChartDialog = ({
 
   return (
     <Dialog
-      small
+      size="small"
       onCloseRequest={handleClose}
       title={t("labels.pasteCharts")}
       className={"PasteChartDialog"}

+ 4 - 1
src/components/ProjectName.tsx

@@ -12,6 +12,7 @@ type Props = {
   onChange: (value: string) => void;
   label: string;
   isNameEditable: boolean;
+  ignoreFocus?: boolean;
 };
 
 export const ProjectName = (props: Props) => {
@@ -19,7 +20,9 @@ export const ProjectName = (props: Props) => {
   const [fileName, setFileName] = useState<string>(props.value);
 
   const handleBlur = (event: any) => {
-    focusNearestParent(event.target);
+    if (!props.ignoreFocus) {
+      focusNearestParent(event.target);
+    }
     const value = event.target.value;
     if (value !== props.value) {
       props.onChange(value);

+ 100 - 0
src/components/RadioGroup.scss

@@ -0,0 +1,100 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  --RadioGroup-background: #ffffff;
+  --RadioGroup-border: var(--color-gray-30);
+
+  --RadioGroup-choice-color-off: var(--color-primary);
+  --RadioGroup-choice-color-off-hover: var(--color-primary-darkest);
+  --RadioGroup-choice-background-off: white;
+  --RadioGroup-choice-background-off-active: var(--color-gray-20);
+
+  --RadioGroup-choice-color-on: white;
+  --RadioGroup-choice-background-on: var(--color-primary);
+  --RadioGroup-choice-background-on-hover: var(--color-primary-darker);
+  --RadioGroup-choice-background-on-active: var(--color-primary-darkest);
+
+  &.theme--dark {
+    --RadioGroup-background: var(--color-gray-85);
+    --RadioGroup-border: var(--color-gray-70);
+
+    --RadioGroup-choice-background-off: var(--color-gray-85);
+    --RadioGroup-choice-background-off-active: var(--color-gray-70);
+    --RadioGroup-choice-color-on: var(--color-gray-85);
+  }
+
+  .RadioGroup {
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: row;
+    align-items: flex-start;
+
+    padding: 3px;
+    border-radius: 10px;
+
+    background: var(--RadioGroup-background);
+    border: 1px solid var(--RadioGroup-border);
+
+    &__choice {
+      position: relative;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 32px;
+      height: 24px;
+
+      color: var(--RadioGroup-choice-color-off);
+      background: var(--RadioGroup-choice-background-off);
+
+      border-radius: 8px;
+
+      font-family: "Assistant";
+      font-style: normal;
+      font-weight: 600;
+      font-size: 0.75rem;
+      line-height: 100%;
+      user-select: none;
+      letter-spacing: 0.4px;
+
+      transition: all 75ms ease-out;
+
+      &:hover {
+        color: var(--RadioGroup-choice-color-off-hover);
+      }
+
+      &:active {
+        background: var(--RadioGroup-choice-background-off-active);
+      }
+
+      &.active {
+        color: var(--RadioGroup-choice-color-on);
+        background: var(--RadioGroup-choice-background-on);
+
+        &:hover {
+          background: var(--RadioGroup-choice-background-on-hover);
+        }
+
+        &:active {
+          background: var(--RadioGroup-choice-background-on-active);
+        }
+      }
+
+      & input {
+        z-index: 1;
+        position: absolute;
+        width: 100%;
+        height: 100%;
+        margin: 0;
+        padding: 0;
+
+        border-radius: 8px;
+
+        -webkit-appearance: none;
+        -moz-appearance: none;
+        appearance: none;
+
+        cursor: pointer;
+      }
+    }
+  }
+}

+ 42 - 0
src/components/RadioGroup.tsx

@@ -0,0 +1,42 @@
+import clsx from "clsx";
+import "./RadioGroup.scss";
+
+export type RadioGroupChoice<T> = {
+  value: T;
+  label: string;
+};
+
+export type RadioGroupProps<T> = {
+  choices: RadioGroupChoice<T>[];
+  value: T;
+  onChange: (value: T) => void;
+  name: string;
+};
+
+export const RadioGroup = function <T>({
+  onChange,
+  value,
+  choices,
+  name,
+}: RadioGroupProps<T>) {
+  return (
+    <div className="RadioGroup">
+      {choices.map((choice) => (
+        <div
+          className={clsx("RadioGroup__choice", {
+            active: choice.value === value,
+          })}
+          key={choice.label}
+        >
+          <input
+            name={name}
+            type="radio"
+            checked={choice.value === value}
+            onChange={() => onChange(choice.value)}
+          />
+          {choice.label}
+        </div>
+      ))}
+    </div>
+  );
+};

+ 116 - 0
src/components/Switch.scss

@@ -0,0 +1,116 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  --Switch-disabled-color: #d6d6d6;
+  --Switch-track-background: white;
+  --Switch-thumb-background: #3d3d3d;
+
+  &.theme--dark {
+    --Switch-disabled-color: #5c5c5c;
+    --Switch-track-background: #242424;
+    --Switch-thumb-background: #b8b8b8;
+  }
+
+  .Switch {
+    position: relative;
+    box-sizing: border-box;
+
+    width: 40px;
+    height: 20px;
+    border-radius: 12px;
+
+    transition-property: background, border;
+    transition-duration: 150ms;
+    transition-timing-function: ease-out;
+
+    background: var(--Switch-track-background);
+    border: 1px solid var(--Switch-disabled-color);
+
+    &:hover {
+      background: var(--Switch-track-background);
+      border: 1px solid #999999;
+    }
+
+    &.toggled {
+      background: var(--color-primary);
+      border: 1px solid var(--color-primary);
+
+      &:hover {
+        background: var(--color-primary-darker);
+        border: 1px solid var(--color-primary-darker);
+      }
+    }
+
+    &.disabled {
+      background: var(--Switch-track-background);
+      border: 1px solid var(--Switch-disabled-color);
+
+      &.toggled {
+        background: var(--Switch-disabled-color);
+        border: 1px solid var(--Switch-disabled-color);
+      }
+    }
+
+    &:before {
+      content: "";
+      box-sizing: border-box;
+      display: block;
+      pointer-events: none;
+      position: absolute;
+
+      border-radius: 100%;
+      transition: all 150ms ease-out;
+
+      width: 10px;
+      height: 10px;
+      top: 4px;
+      left: 4px;
+
+      background: var(--Switch-thumb-background);
+    }
+
+    &:active:before {
+      width: 12px;
+    }
+
+    &.toggled:before {
+      width: 14px;
+      height: 14px;
+      left: 22px;
+      top: 2px;
+
+      background: var(--Switch-track-background);
+    }
+
+    &.toggled:active:before {
+      width: 16px;
+      left: 20px;
+    }
+
+    &.disabled:before {
+      background: var(--Switch-disabled-color);
+    }
+
+    &.disabled.toggled:before {
+      background: var(--color-gray-50);
+    }
+
+    & input {
+      width: 100%;
+      height: 100%;
+      margin: 0;
+
+      border-radius: 12px;
+
+      -webkit-appearance: none;
+      -moz-appearance: none;
+      appearance: none;
+
+      cursor: pointer;
+
+      &:disabled {
+        cursor: unset;
+      }
+    }
+  }
+}

+ 38 - 0
src/components/Switch.tsx

@@ -0,0 +1,38 @@
+import clsx from "clsx";
+
+import "./Switch.scss";
+
+export type SwitchProps = {
+  name: string;
+  checked: boolean;
+  title?: string;
+  onChange: (value: boolean) => void;
+  disabled?: boolean;
+};
+
+export const Switch = ({
+  title,
+  name,
+  checked,
+  onChange,
+  disabled = false,
+}: SwitchProps) => {
+  return (
+    <div className={clsx("Switch", { toggled: checked, disabled })}>
+      <input
+        name={name}
+        id={name}
+        title={title}
+        type="checkbox"
+        checked={checked}
+        disabled={disabled}
+        onChange={() => onChange(!checked)}
+        onKeyDown={(e) => {
+          if (e.key === " ") {
+            onChange(!checked);
+          }
+        }}
+      />
+    </div>
+  );
+};

+ 29 - 0
src/components/icons.tsx

@@ -1550,3 +1550,32 @@ export const handIcon = createIcon(
   </g>,
   tablerIconProps,
 );
+
+export const downloadIcon = createIcon(
+  <>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+    <path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"></path>
+    <path d="M7 11l5 5l5 -5"></path>
+    <path d="M12 4l0 12"></path>
+  </>,
+  tablerIconProps,
+);
+
+export const copyIcon = createIcon(
+  <>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+    <path d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"></path>
+    <path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"></path>
+  </>,
+  tablerIconProps,
+);
+
+export const helpIcon = createIcon(
+  <>
+    <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+    <path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"></path>
+    <path d="M12 17l0 .01"></path>
+    <path d="M12 13.5a1.5 1.5 0 0 1 1 -1.5a2.6 2.6 0 1 0 -3 -4"></path>
+  </>,
+  tablerIconProps,
+);

+ 1 - 1
src/excalidraw-app/collab/RoomDialog.tsx

@@ -180,7 +180,7 @@ const RoomDialog = ({
   };
   return (
     <Dialog
-      small
+      size="small"
       onCloseRequest={handleClose}
       title={t("labels.liveCollaboration")}
       theme={theme}

+ 24 - 9
src/locales/en.json

@@ -40,10 +40,6 @@
     "arrowhead_triangle": "Triangle",
     "fontSize": "Font size",
     "fontFamily": "Font family",
-    "onlySelected": "Only selected",
-    "withBackground": "Background",
-    "exportEmbedScene": "Embed scene",
-    "exportEmbedScene_details": "Scene data will be saved into the exported PNG/SVG file so that the scene can be restored from it.\nWill increase exported file size.",
     "addWatermark": "Add \"Made with Excalidraw\"",
     "handDrawn": "Hand-drawn",
     "normal": "Normal",
@@ -100,7 +96,6 @@
     "flipHorizontal": "Flip horizontal",
     "flipVertical": "Flip vertical",
     "viewMode": "View mode",
-    "toggleExportColorScheme": "Toggle export color scheme",
     "share": "Share",
     "showStroke": "Show stroke color picker",
     "showBackground": "Show background color picker",
@@ -140,11 +135,7 @@
     "exportJSON": "Export to file",
     "exportImage": "Export image...",
     "export": "Save to...",
-    "exportToPng": "Export to PNG",
-    "exportToSvg": "Export to SVG",
     "copyToClipboard": "Copy to clipboard",
-    "copyPngToClipboard": "Copy PNG to clipboard",
-    "scale": "Scale",
     "save": "Save to current file",
     "saveAs": "Save as",
     "load": "Open",
@@ -363,6 +354,30 @@
     "resetLibrary": "Reset library",
     "removeItemsFromLib": "Remove selected items from library"
   },
+  "imageExportDialog": {
+    "header": "Export image",
+    "label": {
+      "withBackground": "Background",
+      "onlySelected": "Only selected",
+      "darkMode": "Dark mode",
+      "embedScene": "Embed scene",
+      "scale": "Scale",
+      "padding": "Padding"
+    },
+    "tooltip": {
+      "embedScene": "Scene data will be saved into the exported PNG/SVG file so that the scene can be restored from it.\nWill increase exported file size."
+    },
+    "title": {
+      "exportToPng": "Export to PNG",
+      "exportToSvg": "Export to SVG",
+      "copyPngToClipboard": "Copy PNG to clipboard"
+    },
+    "button": {
+      "exportToPng": "PNG",
+      "exportToSvg": "SVG",
+      "copyPngToClipboard": "Copy to clipboard"
+    }
+  },
   "encrypted": {
     "tooltip": "Your drawings are end-to-end encrypted so Excalidraw's servers will never see them.",
     "link": "Blog post on end-to-end encryption in Excalidraw"

+ 6 - 5
src/tests/packages/excalidraw.test.tsx

@@ -290,7 +290,7 @@ describe("<Excalidraw/>", () => {
       toggleMenu(container);
       fireEvent.click(queryByTestId(container, "image-export-button")!);
       const textInput: HTMLInputElement | null = document.querySelector(
-        ".ExportDialog .ProjectName .TextInput",
+        ".ImageExportModal .ImageExportModal__preview__filename .TextInput",
       );
       expect(textInput?.value).toContain(`${t("labels.untitled")}`);
       expect(textInput?.nodeName).toBe("INPUT");
@@ -303,10 +303,11 @@ describe("<Excalidraw/>", () => {
       toggleMenu(container);
       await fireEvent.click(queryByTestId(container, "image-export-button")!);
       const textInput = document.querySelector(
-        ".ExportDialog .ProjectName .TextInput--readonly",
-      );
-      expect(textInput?.textContent).toEqual(name);
-      expect(textInput?.nodeName).toBe("SPAN");
+        ".ImageExportModal .ImageExportModal__preview__filename .TextInput",
+      ) as HTMLInputElement;
+      expect(textInput?.value).toEqual(name);
+      expect(textInput?.nodeName).toBe("INPUT");
+      expect(textInput?.disabled).toBe(true);
     });
   });