Prechádzať zdrojové kódy

feat: exporting redesign (#3613)

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
David Luzar 4 rokov pred
rodič
commit
790c9fd02e

+ 20 - 26
src/actions/actionExport.tsx

@@ -11,7 +11,8 @@ import { t } from "../i18n";
 import { useIsMobile } from "../components/App";
 import { KEYS } from "../keys";
 import { register } from "./register";
-import { supported } from "browser-fs-access";
+import { supported as fsSupported } from "browser-fs-access";
+import { CheckboxItem } from "../components/CheckboxItem";
 
 export const actionChangeProjectName = register({
   name: "changeProjectName",
@@ -40,14 +41,12 @@ export const actionChangeExportBackground = register({
     };
   },
   PanelComponent: ({ appState, updateData }) => (
-    <label>
-      <input
-        type="checkbox"
-        checked={appState.exportBackground}
-        onChange={(event) => updateData(event.target.checked)}
-      />{" "}
+    <CheckboxItem
+      checked={appState.exportBackground}
+      onChange={(checked) => updateData(checked)}
+    >
       {t("labels.withBackground")}
-    </label>
+    </CheckboxItem>
   ),
 });
 
@@ -60,17 +59,15 @@ export const actionChangeExportEmbedScene = register({
     };
   },
   PanelComponent: ({ appState, updateData }) => (
-    <label style={{ display: "flex" }}>
-      <input
-        type="checkbox"
-        checked={appState.exportEmbedScene}
-        onChange={(event) => updateData(event.target.checked)}
-      />{" "}
+    <CheckboxItem
+      checked={appState.exportEmbedScene}
+      onChange={(checked) => updateData(checked)}
+    >
       {t("labels.exportEmbedScene")}
       <Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
-        <div className="TooltipIcon">{questionCircle}</div>
+        <div className="Tooltip-icon">{questionCircle}</div>
       </Tooltip>
-    </label>
+    </CheckboxItem>
   ),
 });
 
@@ -83,14 +80,12 @@ export const actionChangeShouldAddWatermark = register({
     };
   },
   PanelComponent: ({ appState, updateData }) => (
-    <label>
-      <input
-        type="checkbox"
-        checked={appState.shouldAddWatermark}
-        onChange={(event) => updateData(event.target.checked)}
-      />{" "}
+    <CheckboxItem
+      checked={appState.shouldAddWatermark}
+      onChange={(checked) => updateData(checked)}
+    >
       {t("labels.addWatermark")}
-    </label>
+    </CheckboxItem>
   ),
 });
 
@@ -126,11 +121,10 @@ export const actionSaveScene = register({
     event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
   PanelComponent: ({ updateData }) => (
     <ToolButton
-      type="button"
+      type="icon"
       icon={save}
       title={t("buttons.save")}
       aria-label={t("buttons.save")}
-      showAriaLabel={useIsMobile()}
       onClick={() => updateData(null)}
       data-testid="save-button"
     />
@@ -162,7 +156,7 @@ export const actionSaveAsScene = register({
       title={t("buttons.saveAs")}
       aria-label={t("buttons.saveAs")}
       showAriaLabel={useIsMobile()}
-      hidden={!supported}
+      hidden={!fsSupported}
       onClick={() => updateData(null)}
       data-testid="save-as-button"
     />

+ 1 - 0
src/actions/types.ts

@@ -131,4 +131,5 @@ export interface ActionsManagerInterface {
   registerAction: (action: Action) => void;
   handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean;
   renderAction: (name: ActionName) => React.ReactElement | null;
+  executeAction: (action: Action) => void;
 }

+ 2 - 2
src/components/App.tsx

@@ -2,7 +2,7 @@ import React, { useContext } from "react";
 import { RoughCanvas } from "roughjs/bin/canvas";
 import rough from "roughjs/bin/rough";
 import clsx from "clsx";
-import { supported } from "browser-fs-access";
+import { supported as fsSupported } from "browser-fs-access";
 import { nanoid } from "nanoid";
 
 import {
@@ -3885,7 +3885,7 @@ class App extends React.Component<AppProps, AppState> {
       // default: assume an Excalidraw file regardless of extension/MimeType
     } else {
       this.setState({ isLoading: true });
-      if (supported) {
+      if (fsSupported) {
         try {
           // This will only work as of Chrome 86,
           // but can be safely ignored on older releases.

+ 6 - 1
src/components/BackgroundPickerAndDarkModeToggle.tsx

@@ -15,6 +15,11 @@ export const BackgroundPickerAndDarkModeToggle = ({
 }) => (
   <div style={{ display: "flex" }}>
     {actionManager.renderAction("changeViewBackgroundColor")}
-    {showThemeBtn && <>{actionManager.renderAction("toggleTheme")}</>}
+    {showThemeBtn && actionManager.renderAction("toggleTheme")}
+    {appState.fileHandle && (
+      <div style={{ marginInlineStart: "0.25rem" }}>
+        {actionManager.renderAction("saveScene")}
+      </div>
+    )}
   </div>
 );

+ 53 - 0
src/components/Card.scss

@@ -0,0 +1,53 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  .Card {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+
+    max-width: 290px;
+
+    margin: 1em;
+
+    text-align: center;
+
+    .Card-icon {
+      font-size: 2.6em;
+      display: flex;
+      flex: 0 0 auto;
+      padding: 1.4rem;
+      border-radius: 50%;
+      background: var(--card-color);
+      color: $oc-white;
+
+      svg {
+        width: 2.8rem;
+        height: 2.8rem;
+      }
+    }
+
+    .Card-details {
+      font-size: 0.96em;
+      min-height: 90px;
+      padding: 0 1em;
+      margin-bottom: auto;
+    }
+
+    & .Card-button.ToolIcon_type_button {
+      height: 2.5rem;
+      margin-top: 1em;
+      margin-bottom: 0.3em;
+      background-color: var(--card-color);
+      &:hover {
+        background-color: var(--card-color-darker);
+      }
+      &:active {
+        background-color: var(--card-color-darkest);
+      }
+      .ToolIcon__label {
+        color: $oc-white;
+      }
+    }
+  }
+}

+ 20 - 0
src/components/Card.tsx

@@ -0,0 +1,20 @@
+import OpenColor from "open-color";
+
+import "./Card.scss";
+
+export const Card: React.FC<{
+  color: keyof OpenColor;
+}> = ({ children, color }) => {
+  return (
+    <div
+      className="Card"
+      style={{
+        ["--card-color" as any]: OpenColor[color][7],
+        ["--card-color-darker" as any]: OpenColor[color][8],
+        ["--card-color-darkest" as any]: OpenColor[color][9],
+      }}
+    >
+      {children}
+    </div>
+  );
+};

+ 85 - 0
src/components/CheckboxItem.scss

@@ -0,0 +1,85 @@
+@import "../css/variables.module";
+
+.excalidraw {
+  .Checkbox {
+    margin: 3px 0.3em;
+    display: flex;
+    align-items: center;
+
+    cursor: pointer;
+    user-select: none;
+
+    &:hover:not(.is-checked) .Checkbox-box {
+      box-shadow: 0 0 0 2px #{$oc-blue-4};
+
+      svg {
+        display: block;
+        opacity: 0.3;
+      }
+    }
+
+    &:active {
+      .Checkbox-box {
+        box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important;
+      }
+    }
+
+    &:hover {
+      .Checkbox-box {
+        background-color: fade-out($oc-blue-1, 0.8);
+      }
+    }
+
+    &.is-checked {
+      .Checkbox-box {
+        background-color: #{$oc-blue-1};
+        svg {
+          display: block;
+        }
+      }
+      &:hover .Checkbox-box {
+        background-color: #{$oc-blue-2};
+      }
+    }
+
+    .Checkbox-box {
+      width: 22px;
+      height: 22px;
+      padding: 0;
+      flex: 0 0 auto;
+
+      margin: 0 1em;
+
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      box-shadow: 0 0 0 2px #{$oc-blue-7};
+      background-color: transparent;
+      border-radius: 4px;
+
+      color: #{$oc-blue-7};
+
+      &:focus {
+        box-shadow: 0 0 0 3px #{$oc-blue-7};
+      }
+
+      svg {
+        display: none;
+        width: 16px;
+        height: 16px;
+        stroke-width: 3px;
+      }
+    }
+
+    .Checkbox-label {
+      display: flex;
+      align-items: center;
+    }
+
+    .Tooltip-icon {
+      width: 1em;
+      height: 1em;
+    }
+  }
+}

+ 26 - 0
src/components/CheckboxItem.tsx

@@ -0,0 +1,26 @@
+import clsx from "clsx";
+import { checkIcon } from "./icons";
+
+import "./CheckboxItem.scss";
+
+export const CheckboxItem: React.FC<{
+  checked: boolean;
+  onChange: (checked: boolean) => void;
+}> = ({ children, checked, onChange }) => {
+  return (
+    <div
+      className={clsx("Checkbox", { "is-checked": checked })}
+      onClick={(event) => {
+        onChange(!checked);
+        ((event.currentTarget as HTMLDivElement).querySelector(
+          ".Checkbox-box",
+        ) as HTMLButtonElement).focus();
+      }}
+    >
+      <button className="Checkbox-box" role="checkbox" aria-checked={checked}>
+        {checkIcon}
+      </button>
+      <div className="Checkbox-label">{children}</div>
+    </div>
+  );
+};

+ 1 - 1
src/components/ColorPicker.scss

@@ -160,7 +160,7 @@
   }
 
   .color-picker-input {
-    width: 12ch; /* length of `transparent` + 1 */
+    width: 11ch; /* length of `transparent` */
     margin: 0;
     font-size: 1rem;
     background-color: var(--input-bg-color);

+ 11 - 22
src/components/DarkModeToggle.tsx

@@ -2,6 +2,7 @@ import "./ToolIcon.scss";
 
 import React from "react";
 import { t } from "../i18n";
+import { ToolButton } from "./ToolButton";
 
 export type Appearence = "light" | "dark";
 
@@ -12,31 +13,19 @@ export const DarkModeToggle = (props: {
   onChange: (value: Appearence) => void;
   title?: string;
 }) => {
-  const title = props.title
-    ? props.title
-    : props.value === "dark"
-    ? t("buttons.lightMode")
-    : t("buttons.darkMode");
+  const title =
+    props.title ||
+    (props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode"));
 
   return (
-    <label
-      className="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
-      data-testid="toggle-dark-mode"
+    <ToolButton
+      type="icon"
+      icon={props.value === "light" ? ICONS.MOON : ICONS.SUN}
       title={title}
-    >
-      <input
-        className="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
-        type="checkbox"
-        onChange={(event) =>
-          props.onChange(event.target.checked ? "dark" : "light")
-        }
-        checked={props.value === "dark"}
-        aria-label={title}
-      />
-      <div className="ToolIcon__icon">
-        {props.value === "light" ? ICONS.MOON : ICONS.SUN}
-      </div>
-    </label>
+      aria-label={title}
+      onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")}
+      data-testid="toggle-dark-mode"
+    />
   );
 };
 

+ 58 - 27
src/components/ExportDialog.scss

@@ -28,33 +28,6 @@
     justify-content: space-between;
   }
 
-  .ExportDialog__name {
-    grid-column: project-name;
-    margin: auto;
-    display: flex;
-    align-items: center;
-
-    .TextInput {
-      height: calc(1rem - 3px);
-      width: 200px;
-      overflow: hidden;
-      text-align: center;
-      margin-left: 8px;
-      text-overflow: ellipsis;
-
-      &--readonly {
-        background: none;
-        border: none;
-        &:hover {
-          background: none;
-        }
-        width: auto;
-        max-width: 200px;
-        padding-left: 2px;
-      }
-    }
-  }
-
   @include isMobile {
     .ExportDialog {
       display: flex;
@@ -84,4 +57,62 @@
       overflow-y: auto;
     }
   }
+
+  .ExportDialog--json {
+    .ExportDialog-cards {
+      display: grid;
+      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+      justify-items: center;
+      row-gap: 2em;
+
+      @media (max-width: 460px) {
+        grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+        .Card-details {
+          min-height: 40px;
+        }
+      }
+
+      .ProjectName {
+        width: fit-content;
+        margin: 1em auto;
+        align-items: flex-start;
+        flex-direction: column;
+
+        .TextInput {
+          width: auto;
+        }
+      }
+
+      .ProjectName-label {
+        margin: 0.625em 0;
+        font-weight: bold;
+      }
+    }
+  }
+
+  button.ExportDialog-imageExportButton {
+    width: 5rem;
+    height: 5rem;
+    margin: 0 0.2em;
+
+    border-radius: 1rem;
+    background-color: var(--button-color);
+    box-shadow: 0 3px 5px -1px rgb(0 0 0 / 28%), 0 6px 10px 0 rgb(0 0 0 / 14%);
+
+    font-family: Cascadia;
+    font-size: 1.8em;
+    color: $oc-white;
+
+    &:hover {
+      background-color: var(--button-color-darker);
+    }
+    &:active {
+      background-color: var(--button-color-darkest);
+      box-shadow: 0 3px 5px -1px rgb(0 0 0 / 20%), 0 6px 10px 0 rgb(0 0 0 / 14%);
+    }
+
+    svg {
+      width: 0.9em;
+    }
+  }
 }

+ 127 - 100
src/components/ExportDialog.tsx → src/components/ImageExportDialog.tsx

@@ -6,16 +6,20 @@ import { canvasToBlob } from "../data/blob";
 import { NonDeletedExcalidrawElement } from "../element/types";
 import { CanvasError } from "../errors";
 import { t } from "../i18n";
-import { useIsMobile } from "../components/App";
+import { useIsMobile } from "./App";
 import { getSelectedElements, isSomeElementSelected } from "../scene";
 import { exportToCanvas, getExportSize } from "../scene/export";
 import { AppState } from "../types";
 import { Dialog } from "./Dialog";
-import "./ExportDialog.scss";
-import { clipboard, exportFile, link } from "./icons";
+import { clipboard, exportImage } from "./icons";
 import Stack from "./Stack";
 import { ToolButton } from "./ToolButton";
 
+import "./ExportDialog.scss";
+import { supported as fsSupported } from "browser-fs-access";
+import OpenColor from "open-color";
+import { CheckboxItem } from "./CheckboxItem";
+
 const scales = [1, 2, 3];
 const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
 
@@ -52,7 +56,30 @@ export type ExportCB = (
   scale?: number,
 ) => void;
 
-const ExportModal = ({
+const ExportButton: React.FC<{
+  color: keyof OpenColor;
+  onClick: () => void;
+  title: string;
+  shade?: number;
+}> = ({ 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>
+  );
+};
+
+const ImageExportModal = ({
   elements,
   appState,
   exportPadding = 10,
@@ -60,7 +87,6 @@ const ExportModal = ({
   onExportToPng,
   onExportToSvg,
   onExportToClipboard,
-  onExportToBackend,
 }: {
   appState: AppState;
   elements: readonly NonDeletedExcalidrawElement[];
@@ -69,7 +95,6 @@ const ExportModal = ({
   onExportToPng: ExportCB;
   onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
-  onExportToBackend?: ExportCB;
   onCloseRequest: () => void;
 }) => {
   const someElementIsSelected = isSomeElementSelected(elements, appState);
@@ -133,98 +158,103 @@ const ExportModal = ({
       <div className="ExportDialog__preview" ref={previewRef} />
       {supportsContextFilters &&
         actionManager.renderAction("exportWithDarkMode")}
-      <Stack.Col gap={2} align="center">
-        <div className="ExportDialog__actions">
-          <Stack.Row gap={2}>
-            <ToolButton
-              type="button"
-              label="PNG"
-              title={t("buttons.exportToPng")}
-              aria-label={t("buttons.exportToPng")}
-              onClick={() => onExportToPng(exportedElements, scale)}
-            />
-            <ToolButton
-              type="button"
-              label="SVG"
-              title={t("buttons.exportToSvg")}
-              aria-label={t("buttons.exportToSvg")}
-              onClick={() => onExportToSvg(exportedElements, scale)}
-            />
-            {probablySupportsClipboardBlob && (
-              <ToolButton
-                type="button"
-                icon={clipboard}
-                title={t("buttons.copyPngToClipboard")}
-                aria-label={t("buttons.copyPngToClipboard")}
-                onClick={() => onExportToClipboard(exportedElements, scale)}
-              />
-            )}
-            {onExportToBackend && (
-              <ToolButton
-                type="button"
-                icon={link}
-                title={t("buttons.getShareableLink")}
-                aria-label={t("buttons.getShareableLink")}
-                onClick={() => onExportToBackend(exportedElements)}
-              />
-            )}
-          </Stack.Row>
-          <div className="ExportDialog__name">
-            {actionManager.renderAction("changeProjectName")}
-          </div>
-          <Stack.Row gap={2}>
-            {scales.map((s) => {
-              const [width, height] = getExportSize(
-                exportedElements,
-                exportPadding,
-                shouldAddWatermark,
-                s,
-              );
+      <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>
+          )}
+          {actionManager.renderAction("changeExportEmbedScene")}
+        </div>
+      </div>
+      <div style={{ display: "flex", alignItems: "center", marginTop: ".6em" }}>
+        <Stack.Row gap={2} justifyContent={"center"}>
+          {scales.map((_scale) => {
+            const [width, height] = getExportSize(
+              exportedElements,
+              exportPadding,
+              shouldAddWatermark,
+              _scale,
+            );
 
-              const scaleButtonTitle = `${t(
-                "buttons.scale",
-              )} ${s}x (${width}x${height})`;
+            const scaleButtonTitle = `${t(
+              "buttons.scale",
+            )} ${_scale}x (${width}x${height})`;
 
-              return (
-                <ToolButton
-                  key={s}
-                  size="s"
-                  type="radio"
-                  icon={`${s}x`}
-                  name="export-canvas-scale"
-                  title={scaleButtonTitle}
-                  aria-label={scaleButtonTitle}
-                  id="export-canvas-scale"
-                  checked={s === scale}
-                  onChange={() => setScale(s)}
-                />
-              );
-            })}
-          </Stack.Row>
-        </div>
-        {actionManager.renderAction("changeExportBackground")}
-        {someElementIsSelected && (
-          <div>
-            <label>
-              <input
-                type="checkbox"
-                checked={exportSelected}
-                onChange={(event) =>
-                  setExportSelected(event.currentTarget.checked)
-                }
-              />{" "}
-              {t("labels.onlySelected")}
-            </label>
-          </div>
+            return (
+              <ToolButton
+                key={_scale}
+                size="s"
+                type="radio"
+                icon={`${_scale}x`}
+                name="export-canvas-scale"
+                title={scaleButtonTitle}
+                aria-label={scaleButtonTitle}
+                id="export-canvas-scale"
+                checked={_scale === scale}
+                onChange={() => setScale(_scale)}
+              />
+            );
+          })}
+        </Stack.Row>
+        <p style={{ marginLeft: "1em", userSelect: "none" }}>Scale</p>
+      </div>
+      <div
+        style={{
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+          margin: ".6em 0",
+        }}
+      >
+        {!fsSupported && 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={() => onExportToPng(exportedElements, scale)}
+        >
+          PNG
+        </ExportButton>
+        <ExportButton
+          color="red"
+          title={t("buttons.exportToSvg")}
+          aria-label={t("buttons.exportToSvg")}
+          onClick={() => onExportToSvg(exportedElements, scale)}
+        >
+          SVG
+        </ExportButton>
+        {probablySupportsClipboardBlob && (
+          <ExportButton
+            title={t("buttons.copyPngToClipboard")}
+            onClick={() => onExportToClipboard(exportedElements, scale)}
+            color="gray"
+            shade={7}
+          >
+            {clipboard}
+          </ExportButton>
         )}
-        {actionManager.renderAction("changeExportEmbedScene")}
-        {actionManager.renderAction("changeShouldAddWatermark")}
-      </Stack.Col>
+      </Stack.Row>
     </div>
   );
 };
 
-export const ExportDialog = ({
+export const ImageExportDialog = ({
   elements,
   appState,
   exportPadding = 10,
@@ -232,7 +262,6 @@ export const ExportDialog = ({
   onExportToPng,
   onExportToSvg,
   onExportToClipboard,
-  onExportToBackend,
 }: {
   appState: AppState;
   elements: readonly NonDeletedExcalidrawElement[];
@@ -241,7 +270,6 @@ export const ExportDialog = ({
   onExportToPng: ExportCB;
   onExportToSvg: ExportCB;
   onExportToClipboard: ExportCB;
-  onExportToBackend?: ExportCB;
 }) => {
   const [modalIsShown, setModalIsShown] = useState(false);
 
@@ -255,16 +283,16 @@ export const ExportDialog = ({
         onClick={() => {
           setModalIsShown(true);
         }}
-        data-testid="export-button"
-        icon={exportFile}
+        data-testid="image-export-button"
+        icon={exportImage}
         type="button"
-        aria-label={t("buttons.export")}
+        aria-label={t("buttons.exportImage")}
         showAriaLabel={useIsMobile()}
-        title={t("buttons.export")}
+        title={t("buttons.exportImage")}
       />
       {modalIsShown && (
-        <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
-          <ExportModal
+        <Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}>
+          <ImageExportModal
             elements={elements}
             appState={appState}
             exportPadding={exportPadding}
@@ -272,7 +300,6 @@ export const ExportDialog = ({
             onExportToPng={onExportToPng}
             onExportToSvg={onExportToSvg}
             onExportToClipboard={onExportToClipboard}
-            onExportToBackend={onExportToBackend}
             onCloseRequest={handleClose}
           />
         </Dialog>

+ 117 - 0
src/components/JSONExportDialog.tsx

@@ -0,0 +1,117 @@
+import React, { useState } from "react";
+import { ActionsManagerInterface } from "../actions/types";
+import { NonDeletedExcalidrawElement } from "../element/types";
+import { t } from "../i18n";
+import { useIsMobile } from "./App";
+import { AppState } from "../types";
+import { Dialog } from "./Dialog";
+import { exportFile, exportToFileIcon, link } from "./icons";
+import { ToolButton } from "./ToolButton";
+import { actionSaveAsScene } from "../actions/actionExport";
+import { Card } from "./Card";
+
+import "./ExportDialog.scss";
+import { supported as fsSupported } from "browser-fs-access";
+
+export type ExportCB = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  scale?: number,
+) => void;
+
+const JSONExportModal = ({
+  elements,
+  appState,
+  actionManager,
+  onExportToBackend,
+}: {
+  appState: AppState;
+  elements: readonly NonDeletedExcalidrawElement[];
+  actionManager: ActionsManagerInterface;
+  onExportToBackend?: ExportCB;
+  onCloseRequest: () => void;
+}) => {
+  return (
+    <div className="ExportDialog ExportDialog--json">
+      <div className="ExportDialog-cards">
+        <Card color="lime">
+          <div className="Card-icon">{exportToFileIcon}</div>
+          <h2>{t("exportDialog.disk_title")}</h2>
+          <div className="Card-details">
+            {t("exportDialog.disk_details")}
+            {!fsSupported && actionManager.renderAction("changeProjectName")}
+          </div>
+          <ToolButton
+            className="Card-button"
+            type="button"
+            title={t("exportDialog.disk_button")}
+            aria-label={t("exportDialog.disk_button")}
+            showAriaLabel={true}
+            onClick={() => {
+              actionManager.executeAction(actionSaveAsScene);
+            }}
+          />
+        </Card>
+        {onExportToBackend && (
+          <Card color="pink">
+            <div className="Card-icon">{link}</div>
+            <h2>{t("exportDialog.link_title")}</h2>
+            <div className="Card-details">{t("exportDialog.link_details")}</div>
+            <ToolButton
+              className="Card-button"
+              type="button"
+              title={t("exportDialog.link_button")}
+              aria-label={t("exportDialog.link_button")}
+              showAriaLabel={true}
+              onClick={() => onExportToBackend(elements)}
+            />
+          </Card>
+        )}
+      </div>
+    </div>
+  );
+};
+
+export const JSONExportDialog = ({
+  elements,
+  appState,
+  actionManager,
+  onExportToBackend,
+}: {
+  appState: AppState;
+  elements: readonly NonDeletedExcalidrawElement[];
+  actionManager: ActionsManagerInterface;
+  onExportToBackend?: ExportCB;
+}) => {
+  const [modalIsShown, setModalIsShown] = useState(false);
+
+  const handleClose = React.useCallback(() => {
+    setModalIsShown(false);
+  }, []);
+
+  return (
+    <>
+      <ToolButton
+        onClick={() => {
+          setModalIsShown(true);
+        }}
+        data-testid="json-export-button"
+        icon={exportFile}
+        type="button"
+        aria-label={t("buttons.export")}
+        showAriaLabel={useIsMobile()}
+        title={t("buttons.export")}
+      />
+      {modalIsShown && (
+        <Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
+          <JSONExportModal
+            elements={elements}
+            appState={appState}
+            actionManager={actionManager}
+            onExportToBackend={onExportToBackend}
+            onCloseRequest={handleClose}
+          />
+        </Dialog>
+      )}
+    </>
+  );
+};

+ 39 - 19
src/components/LayerUI.tsx

@@ -28,7 +28,7 @@ import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
 import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
 import CollabButton from "./CollabButton";
 import { ErrorDialog } from "./ErrorDialog";
-import { ExportCB, ExportDialog } from "./ExportDialog";
+import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
 import { FixedSideContainer } from "./FixedSideContainer";
 import { HintViewer } from "./HintViewer";
 import { exportFile, load, trash } from "./icons";
@@ -46,6 +46,7 @@ import { ToolButton } from "./ToolButton";
 import { Tooltip } from "./Tooltip";
 import { UserList } from "./UserList";
 import Library from "../data/library";
+import { JSONExportDialog } from "./JSONExportDialog";
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -382,7 +383,29 @@ const LayerUI = ({
 }: LayerUIProps) => {
   const isMobile = useIsMobile();
 
-  const renderExportDialog = () => {
+  const renderJSONExportDialog = () => {
+    if (!UIOptions.canvasActions.export) {
+      return null;
+    }
+
+    return (
+      <JSONExportDialog
+        elements={elements}
+        appState={appState}
+        actionManager={actionManager}
+        onExportToBackend={
+          onExportToBackend
+            ? (elements) => {
+                onExportToBackend &&
+                  onExportToBackend(elements, appState, canvas);
+              }
+            : undefined
+        }
+      />
+    );
+  };
+
+  const renderImageExportDialog = () => {
     if (!UIOptions.canvasActions.export) {
       return null;
     }
@@ -406,25 +429,21 @@ const LayerUI = ({
     };
 
     return (
-      <ExportDialog
+      <ImageExportDialog
         elements={elements}
         appState={appState}
         actionManager={actionManager}
         onExportToPng={createExporter("png")}
         onExportToSvg={createExporter("svg")}
         onExportToClipboard={createExporter("clipboard")}
-        onExportToBackend={
-          onExportToBackend
-            ? (elements) => {
-                onExportToBackend &&
-                  onExportToBackend(elements, appState, canvas);
-              }
-            : undefined
-        }
       />
     );
   };
 
+  const Separator = () => {
+    return <div style={{ width: ".625em" }} />;
+  };
+
   const renderViewModeCanvasActions = () => {
     return (
       <Section
@@ -438,9 +457,8 @@ const LayerUI = ({
         <Island padding={2} style={{ zIndex: 1 }}>
           <Stack.Col gap={4}>
             <Stack.Row gap={1} justifyContent="space-between">
-              {actionManager.renderAction("saveScene")}
-              {actionManager.renderAction("saveAsScene")}
-              {renderExportDialog()}
+              {renderJSONExportDialog()}
+              {renderImageExportDialog()}
             </Stack.Row>
           </Stack.Col>
         </Island>
@@ -459,11 +477,12 @@ const LayerUI = ({
       <Island padding={2} style={{ zIndex: 1 }}>
         <Stack.Col gap={4}>
           <Stack.Row gap={1} justifyContent="space-between">
-            {actionManager.renderAction("loadScene")}
-            {actionManager.renderAction("saveScene")}
-            {actionManager.renderAction("saveAsScene")}
-            {renderExportDialog()}
             {actionManager.renderAction("clearCanvas")}
+            <Separator />
+            {actionManager.renderAction("loadScene")}
+            {renderJSONExportDialog()}
+            {renderImageExportDialog()}
+            <Separator />
             {onCollabButtonClick && (
               <CollabButton
                 isCollaborating={isCollaborating}
@@ -712,7 +731,8 @@ const LayerUI = ({
         elements={elements}
         actionManager={actionManager}
         libraryMenu={libraryMenu}
-        exportButton={renderExportDialog()}
+        renderJSONExportDialog={renderJSONExportDialog}
+        renderImageExportDialog={renderImageExportDialog}
         setAppState={setAppState}
         onCollabButtonClick={onCollabButtonClick}
         onLockToggle={onLockToggle}

+ 9 - 9
src/components/MobileMenu.tsx

@@ -20,7 +20,8 @@ import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkMode
 type MobileMenuProps = {
   appState: AppState;
   actionManager: ActionManager;
-  exportButton: React.ReactNode;
+  renderJSONExportDialog: () => React.ReactNode;
+  renderImageExportDialog: () => React.ReactNode;
   setAppState: React.Component<any, AppState>["setState"];
   elements: readonly NonDeletedExcalidrawElement[];
   libraryMenu: JSX.Element | null;
@@ -38,7 +39,8 @@ export const MobileMenu = ({
   elements,
   libraryMenu,
   actionManager,
-  exportButton,
+  renderJSONExportDialog,
+  renderImageExportDialog,
   setAppState,
   onCollabButtonClick,
   onLockToggle,
@@ -107,19 +109,17 @@ export const MobileMenu = ({
     if (viewModeEnabled) {
       return (
         <>
-          {actionManager.renderAction("saveScene")}
-          {actionManager.renderAction("saveAsScene")}
-          {exportButton}
+          {renderJSONExportDialog()}
+          {renderImageExportDialog()}
         </>
       );
     }
     return (
       <>
-        {actionManager.renderAction("loadScene")}
-        {actionManager.renderAction("saveScene")}
-        {actionManager.renderAction("saveAsScene")}
-        {exportButton}
         {actionManager.renderAction("clearCanvas")}
+        {actionManager.renderAction("loadScene")}
+        {renderJSONExportDialog()}
+        {renderImageExportDialog()}
         {onCollabButtonClick && (
           <CollabButton
             isCollaborating={isCollaborating}

+ 25 - 0
src/components/ProjectName.scss

@@ -0,0 +1,25 @@
+.ProjectName {
+  margin: auto;
+  display: flex;
+  align-items: center;
+
+  .TextInput {
+    height: calc(1rem - 3px);
+    width: 200px;
+    overflow: hidden;
+    text-align: center;
+    margin-left: 8px;
+    text-overflow: ellipsis;
+
+    &--readonly {
+      background: none;
+      border: none;
+      &:hover {
+        background: none;
+      }
+      width: auto;
+      max-width: 200px;
+      padding-left: 2px;
+    }
+  }
+}

+ 7 - 5
src/components/ProjectName.tsx

@@ -3,6 +3,8 @@ import "./TextInput.scss";
 import React, { Component } from "react";
 import { focusNearestParent } from "../utils";
 
+import "./ProjectName.scss";
+
 type Props = {
   value: string;
   onChange: (value: string) => void;
@@ -37,8 +39,8 @@ export class ProjectName extends Component<Props, State> {
 
   public render() {
     return (
-      <>
-        <label htmlFor="file-name">
+      <div className="ProjectName">
+        <label className="ProjectName-label" htmlFor="filename">
           {`${this.props.label}${this.props.isNameEditable ? "" : ":"}`}
         </label>
         {this.props.isNameEditable ? (
@@ -46,18 +48,18 @@ export class ProjectName extends Component<Props, State> {
             className="TextInput"
             onBlur={this.handleBlur}
             onKeyDown={this.handleKeyDown}
-            id="file-name"
+            id="filename"
             value={this.state.fileName}
             onChange={(event) =>
               this.setState({ fileName: event.target.value })
             }
           />
         ) : (
-          <span className="TextInput TextInput--readonly" id="file-name">
+          <span className="TextInput TextInput--readonly" id="filename">
             {this.props.value}
           </span>
         )}
-      </>
+      </div>
     );
   }
 }

+ 1 - 1
src/components/Stats.scss

@@ -6,7 +6,7 @@
     top: 64px;
     right: 12px;
     font-size: 12px;
-    z-index: 999;
+    z-index: 10;
 
     h3 {
       margin: 0 24px 8px 0;

+ 17 - 10
src/components/ToolButton.tsx

@@ -30,8 +30,12 @@ type ToolButtonProps =
       onClick?(): void;
     })
   | (ToolButtonBaseProps & {
+      type: "icon";
+      children?: React.ReactNode;
+      onClick?(): void;
+    })
+  | (ToolButtonBaseProps & {
       type: "radio";
-
       checked: boolean;
       onChange?(): void;
     });
@@ -43,7 +47,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
   React.useImperativeHandle(ref, () => innerRef.current);
   const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
 
-  if (props.type === "button") {
+  if (props.type === "button" || props.type === "icon") {
     return (
       <button
         className={clsx(
@@ -56,6 +60,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
           {
             ToolIcon: !props.hidden,
             "ToolIcon--selected": props.selected,
+            "ToolIcon--plain": props.type === "icon",
           },
         )}
         data-testid={props["data-testid"]}
@@ -66,14 +71,16 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
         onClick={props.onClick}
         ref={innerRef}
       >
-        <div className="ToolIcon__icon" aria-hidden="true">
-          {props.icon || props.label}
-          {props.keyBindingLabel && (
-            <span className="ToolIcon__keybinding">
-              {props.keyBindingLabel}
-            </span>
-          )}
-        </div>
+        {(props.icon || props.label) && (
+          <div className="ToolIcon__icon" aria-hidden="true">
+            {props.icon || props.label}
+            {props.keyBindingLabel && (
+              <span className="ToolIcon__keybinding">
+                {props.keyBindingLabel}
+              </span>
+            )}
+          </div>
+        )}
         {props.showAriaLabel && (
           <div className="ToolIcon__label">{props["aria-label"]}</div>
         )}

+ 9 - 11
src/components/ToolIcon.scss

@@ -11,6 +11,15 @@
     background-color: var(--button-gray-1);
     -webkit-tap-highlight-color: transparent;
     border-radius: var(--space-factor);
+    user-select: none;
+  }
+
+  .ToolIcon--plain {
+    background-color: transparent;
+    .ToolIcon__icon {
+      width: 2rem;
+      height: 2rem;
+    }
   }
 
   .ToolIcon__icon {
@@ -187,17 +196,6 @@
     }
   }
 
-  .TooltipIcon {
-    width: 0.9em;
-    height: 0.9em;
-    margin-left: 5px;
-    margin-top: 1px;
-
-    @include isMobile {
-      display: none;
-    }
-  }
-
   .unlocked-icon {
     :root[dir="ltr"] & {
       left: 2px;

+ 14 - 0
src/components/Tooltip.scss

@@ -23,3 +23,17 @@
     display: block;
   }
 }
+
+.excalidraw {
+  .Tooltip-icon {
+    width: 0.9em;
+    height: 0.9em;
+    margin-left: 5px;
+    margin-top: 1px;
+    display: flex;
+
+    @include isMobile {
+      display: none;
+    }
+  }
+}

+ 27 - 8
src/components/icons.tsx

@@ -41,6 +41,14 @@ const createIcon = (d: string | React.ReactNode, opts: number | Opts = 512) => {
   );
 };
 
+export const checkIcon = createIcon(
+  <polyline fill="none" stroke="currentColor" points="20 6 9 17 4 12" />,
+  {
+    width: 24,
+    height: 24,
+  },
+);
+
 export const link = createIcon(
   "M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z",
   { mirror: true },
@@ -80,6 +88,25 @@ export const exportFile = createIcon(
   { width: 576, height: 512, mirror: true },
 );
 
+export const exportImage = createIcon(
+  <>
+    <path
+      d="M571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-187 44v-64 64z"
+      fill-rule="nonzero"
+    />
+    <path
+      d="M384 121.941V128H256V0h6.059c6.362 0 12.471 2.53 16.97 7.029l97.941 97.941a24.01 24.01 0 017.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z"
+      fill-rule="nonzero"
+    />
+  </>,
+  { width: 576, height: 512, mirror: true },
+);
+
+export const exportToFileIcon = createIcon(
+  "M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z",
+  { width: 512, height: 512 },
+);
+
 export const zoomIn = createIcon(
   "M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z",
   { width: 448, height: 512 },
@@ -350,14 +377,6 @@ export const DistributeHorizontallyIcon = React.memo(
     ),
 );
 
-<svg
-  width="24"
-  height="24"
-  viewBox="0 0 24 24"
-  fill="none"
-  xmlns="http://www.w3.org/2000/svg"
-></svg>;
-
 export const DistributeVerticallyIcon = React.memo(
   ({ theme }: { theme: "light" | "dark" }) =>
     createIcon(

+ 13 - 3
src/locales/en.json

@@ -42,8 +42,8 @@
     "fontSize": "Font size",
     "fontFamily": "Font family",
     "onlySelected": "Only selected",
-    "withBackground": "With background",
-    "exportEmbedScene": "Embed scene into exported file",
+    "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",
@@ -105,13 +105,15 @@
   },
   "buttons": {
     "clearReset": "Reset the canvas",
+    "exportJSON": "Export to file",
+    "exportImage": "Save as image",
     "export": "Export",
     "exportToPng": "Export to PNG",
     "exportToSvg": "Export to SVG",
     "copyToClipboard": "Copy to clipboard",
     "copyPngToClipboard": "Copy PNG to clipboard",
     "scale": "Scale",
-    "save": "Save",
+    "save": "Save to current file",
     "saveAs": "Save as",
     "load": "Load",
     "getShareableLink": "Get shareable link",
@@ -215,6 +217,14 @@
   "errorDialog": {
     "title": "Error"
   },
+  "exportDialog": {
+    "disk_title": "Save to disk",
+    "disk_details": "Export the scene data to a file from which you can import later.",
+    "disk_button": "Save to file",
+    "link_title": "Shareable link",
+    "link_details": "Export as a read-only link.",
+    "link_button": "Export to Link"
+  },
   "helpDialog": {
     "blog": "Read our blog",
     "click": "click",

+ 70 - 106
src/tests/__snapshots__/excalidrawPackage.test.tsx.snap

@@ -24,35 +24,10 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
         style="--gap: 1; justify-content: space-between;"
       >
         <button
-          aria-label="Load"
-          class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
-          data-testid="load-button"
-          title="Load"
-          type="button"
-        >
-          <div
-            aria-hidden="true"
-            class="ToolIcon__icon"
-          >
-            <svg
-              aria-hidden="true"
-              class="rtl-mirror"
-              focusable="false"
-              role="img"
-              viewBox="0 0 576 512"
-            >
-              <path
-                d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
-                fill="currentColor"
-              />
-            </svg>
-          </div>
-        </button>
-        <button
-          aria-label="Save"
+          aria-label="Reset the canvas"
           class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
-          data-testid="save-button"
-          title="Save"
+          data-testid="clear-canvas-button"
+          title="Reset the canvas"
           type="button"
         >
           <div
@@ -67,18 +42,20 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
               viewBox="0 0 448 512"
             >
               <path
-                d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z"
+                d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
                 fill="currentColor"
               />
             </svg>
           </div>
         </button>
+        <div
+          style="width: .625em;"
+        />
         <button
-          aria-label="Save as"
-          class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--hide"
-          data-testid="save-as-button"
-          hidden=""
-          title="Save as"
+          aria-label="Load"
+          class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
+          data-testid="load-button"
+          title="Load"
           type="button"
         >
           <div
@@ -87,13 +64,13 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
           >
             <svg
               aria-hidden="true"
-              class=""
+              class="rtl-mirror"
               focusable="false"
               role="img"
-              viewBox="0 0 448 512"
+              viewBox="0 0 576 512"
             >
               <path
-                d="M252 54L203 8a28 27 0 00-20-8H28C12 0 0 12 0 27v195c0 15 12 26 28 26h204c15 0 28-11 28-26V73a28 27 0 00-8-19zM130 213c-21 0-37-16-37-36 0-19 16-35 37-35 20 0 37 16 37 35 0 20-17 36-37 36zm56-169v56c0 4-4 6-7 6H44c-4 0-7-2-7-6V42c0-4 3-7 7-7h133l4 2 3 2a7 7 0 012 5z M296 201l87 95-188 205-78 9c-10 1-19-8-18-20l9-84zm141-14l-41-44a31 31 0 00-46 0l-38 41 87 95 38-42c13-14 13-36 0-50z"
+                d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
                 fill="currentColor"
               />
             </svg>
@@ -102,7 +79,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
         <button
           aria-label="Export"
           class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
-          data-testid="export-button"
+          data-testid="json-export-button"
           title="Export"
           type="button"
         >
@@ -125,10 +102,10 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
           </div>
         </button>
         <button
-          aria-label="Reset the canvas"
+          aria-label="Save as image"
           class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
-          data-testid="clear-canvas-button"
-          title="Reset the canvas"
+          data-testid="image-export-button"
+          title="Save as image"
           type="button"
         >
           <div
@@ -137,18 +114,25 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
           >
             <svg
               aria-hidden="true"
-              class=""
+              class="rtl-mirror"
               focusable="false"
               role="img"
-              viewBox="0 0 448 512"
+              viewBox="0 0 576 512"
             >
               <path
-                d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
-                fill="currentColor"
+                d="M571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-187 44v-64 64z"
+                fill-rule="nonzero"
+              />
+              <path
+                d="M384 121.941V128H256V0h6.059c6.362 0 12.471 2.53 16.97 7.029l97.941 97.941a24.01 24.01 0 017.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z"
+                fill-rule="nonzero"
               />
             </svg>
           </div>
         </button>
+        <div
+          style="width: .625em;"
+        />
       </div>
       <div
         style="display: flex;"
@@ -186,17 +170,15 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
         <div
           style="margin-inline-start: 0.25rem;"
         >
-          <label
-            class="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
+          <button
+            aria-label="Dark mode"
+            class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon ToolIcon--plain"
             data-testid="toggle-dark-mode"
             title="Dark mode"
+            type="button"
           >
-            <input
-              aria-label="Dark mode"
-              class="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
-              type="checkbox"
-            />
             <div
+              aria-hidden="true"
               class="ToolIcon__icon"
             >
               <svg
@@ -211,7 +193,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
                 />
               </svg>
             </div>
-          </label>
+          </button>
         </div>
       </div>
     </div>
@@ -243,35 +225,10 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
         style="--gap: 1; justify-content: space-between;"
       >
         <button
-          aria-label="Load"
-          class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
-          data-testid="load-button"
-          title="Load"
-          type="button"
-        >
-          <div
-            aria-hidden="true"
-            class="ToolIcon__icon"
-          >
-            <svg
-              aria-hidden="true"
-              class="rtl-mirror"
-              focusable="false"
-              role="img"
-              viewBox="0 0 576 512"
-            >
-              <path
-                d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
-                fill="currentColor"
-              />
-            </svg>
-          </div>
-        </button>
-        <button
-          aria-label="Save"
+          aria-label="Reset the canvas"
           class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
-          data-testid="save-button"
-          title="Save"
+          data-testid="clear-canvas-button"
+          title="Reset the canvas"
           type="button"
         >
           <div
@@ -286,18 +243,20 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
               viewBox="0 0 448 512"
             >
               <path
-                d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z"
+                d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
                 fill="currentColor"
               />
             </svg>
           </div>
         </button>
+        <div
+          style="width: .625em;"
+        />
         <button
-          aria-label="Save as"
-          class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--hide"
-          data-testid="save-as-button"
-          hidden=""
-          title="Save as"
+          aria-label="Load"
+          class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
+          data-testid="load-button"
+          title="Load"
           type="button"
         >
           <div
@@ -306,13 +265,13 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
           >
             <svg
               aria-hidden="true"
-              class=""
+              class="rtl-mirror"
               focusable="false"
               role="img"
-              viewBox="0 0 448 512"
+              viewBox="0 0 576 512"
             >
               <path
-                d="M252 54L203 8a28 27 0 00-20-8H28C12 0 0 12 0 27v195c0 15 12 26 28 26h204c15 0 28-11 28-26V73a28 27 0 00-8-19zM130 213c-21 0-37-16-37-36 0-19 16-35 37-35 20 0 37 16 37 35 0 20-17 36-37 36zm56-169v56c0 4-4 6-7 6H44c-4 0-7-2-7-6V42c0-4 3-7 7-7h133l4 2 3 2a7 7 0 012 5z M296 201l87 95-188 205-78 9c-10 1-19-8-18-20l9-84zm141-14l-41-44a31 31 0 00-46 0l-38 41 87 95 38-42c13-14 13-36 0-50z"
+                d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
                 fill="currentColor"
               />
             </svg>
@@ -321,7 +280,7 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
         <button
           aria-label="Export"
           class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
-          data-testid="export-button"
+          data-testid="json-export-button"
           title="Export"
           type="button"
         >
@@ -344,10 +303,10 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
           </div>
         </button>
         <button
-          aria-label="Reset the canvas"
+          aria-label="Save as image"
           class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
-          data-testid="clear-canvas-button"
-          title="Reset the canvas"
+          data-testid="image-export-button"
+          title="Save as image"
           type="button"
         >
           <div
@@ -356,18 +315,25 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
           >
             <svg
               aria-hidden="true"
-              class=""
+              class="rtl-mirror"
               focusable="false"
               role="img"
-              viewBox="0 0 448 512"
+              viewBox="0 0 576 512"
             >
               <path
-                d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
-                fill="currentColor"
+                d="M571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-187 44v-64 64z"
+                fill-rule="nonzero"
+              />
+              <path
+                d="M384 121.941V128H256V0h6.059c6.362 0 12.471 2.53 16.97 7.029l97.941 97.941a24.01 24.01 0 017.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z"
+                fill-rule="nonzero"
               />
             </svg>
           </div>
         </button>
+        <div
+          style="width: .625em;"
+        />
       </div>
       <div
         style="display: flex;"
@@ -405,17 +371,15 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
         <div
           style="margin-inline-start: 0.25rem;"
         >
-          <label
-            class="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
+          <button
+            aria-label="Dark mode"
+            class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon ToolIcon--plain"
             data-testid="toggle-dark-mode"
             title="Dark mode"
+            type="button"
           >
-            <input
-              aria-label="Dark mode"
-              class="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
-              type="checkbox"
-            />
             <div
+              aria-hidden="true"
               class="ToolIcon__icon"
             >
               <svg
@@ -430,7 +394,7 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
                 />
               </svg>
             </div>
-          </label>
+          </button>
         </div>
       </div>
     </div>

+ 6 - 5
src/tests/excalidrawPackage.test.tsx

@@ -110,9 +110,9 @@ describe("<Excalidraw/>", () => {
     it('should allow editing name when the name prop is "undefined"', async () => {
       const { container } = await render(<Excalidraw />);
 
-      fireEvent.click(queryByTestId(container, "export-button")!);
+      fireEvent.click(queryByTestId(container, "image-export-button")!);
       const textInput: HTMLInputElement | null = document.querySelector(
-        ".ExportDialog__name .TextInput",
+        ".ExportDialog .ProjectName .TextInput",
       );
       expect(textInput?.value).toContain(`${t("labels.untitled")}`);
       expect(textInput?.nodeName).toBe("INPUT");
@@ -122,9 +122,9 @@ describe("<Excalidraw/>", () => {
       const name = "test";
       const { container } = await render(<Excalidraw name={name} />);
 
-      await fireEvent.click(queryByTestId(container, "export-button")!);
+      await fireEvent.click(queryByTestId(container, "image-export-button")!);
       const textInput = document.querySelector(
-        ".ExportDialog__name .TextInput--readonly",
+        ".ExportDialog .ProjectName .TextInput--readonly",
       );
       expect(textInput?.textContent).toEqual(name);
       expect(textInput?.nodeName).toBe("SPAN");
@@ -166,7 +166,8 @@ describe("<Excalidraw/>", () => {
           <Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
         );
 
-        expect(queryByTestId(container, "export-button")).toBeNull();
+        expect(queryByTestId(container, "json-export-button")).toBeNull();
+        expect(queryByTestId(container, "image-export-button")).toBeNull();
       });
 
       it("should hide load button when loadScene is false", async () => {