feat: exporting redesign (#3613)

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
This commit is contained in:
David Luzar 2021-05-25 21:37:14 +02:00 committed by GitHub
parent 357266e9ab
commit 790c9fd02e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 793 additions and 385 deletions

View File

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

View File

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

View File

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

View File

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

53
src/components/Card.scss Normal file
View File

@ -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
src/components/Card.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,33 +28,6 @@
justify-content: space-between; 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 { @include isMobile {
.ExportDialog { .ExportDialog {
display: flex; display: flex;
@ -84,4 +57,62 @@
overflow-y: auto; 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;
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,15 @@
background-color: var(--button-gray-1); background-color: var(--button-gray-1);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
border-radius: var(--space-factor); border-radius: var(--space-factor);
user-select: none;
}
.ToolIcon--plain {
background-color: transparent;
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
} }
.ToolIcon__icon { .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 { .unlocked-icon {
:root[dir="ltr"] & { :root[dir="ltr"] & {
left: 2px; left: 2px;

View File

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

View File

@ -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( 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", "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 }, { mirror: true },
@ -80,6 +88,25 @@ export const exportFile = createIcon(
{ width: 576, height: 512, mirror: true }, { 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( 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", "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 }, { 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( export const DistributeVerticallyIcon = React.memo(
({ theme }: { theme: "light" | "dark" }) => ({ theme }: { theme: "light" | "dark" }) =>
createIcon( createIcon(

View File

@ -42,8 +42,8 @@
"fontSize": "Font size", "fontSize": "Font size",
"fontFamily": "Font family", "fontFamily": "Font family",
"onlySelected": "Only selected", "onlySelected": "Only selected",
"withBackground": "With background", "withBackground": "Background",
"exportEmbedScene": "Embed scene into exported file", "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.", "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\"", "addWatermark": "Add \"Made with Excalidraw\"",
"handDrawn": "Hand-drawn", "handDrawn": "Hand-drawn",
@ -105,13 +105,15 @@
}, },
"buttons": { "buttons": {
"clearReset": "Reset the canvas", "clearReset": "Reset the canvas",
"exportJSON": "Export to file",
"exportImage": "Save as image",
"export": "Export", "export": "Export",
"exportToPng": "Export to PNG", "exportToPng": "Export to PNG",
"exportToSvg": "Export to SVG", "exportToSvg": "Export to SVG",
"copyToClipboard": "Copy to clipboard", "copyToClipboard": "Copy to clipboard",
"copyPngToClipboard": "Copy PNG to clipboard", "copyPngToClipboard": "Copy PNG to clipboard",
"scale": "Scale", "scale": "Scale",
"save": "Save", "save": "Save to current file",
"saveAs": "Save as", "saveAs": "Save as",
"load": "Load", "load": "Load",
"getShareableLink": "Get shareable link", "getShareableLink": "Get shareable link",
@ -215,6 +217,14 @@
"errorDialog": { "errorDialog": {
"title": "Error" "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": { "helpDialog": {
"blog": "Read our blog", "blog": "Read our blog",
"click": "click", "click": "click",

View File

@ -23,6 +23,34 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
class="Stack Stack_horizontal" class="Stack Stack_horizontal"
style="--gap: 1; justify-content: space-between;" style="--gap: 1; justify-content: space-between;"
> >
<button
aria-label="Reset the canvas"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 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"
/>
</svg>
</div>
</button>
<div
style="width: .625em;"
/>
<button <button
aria-label="Load" aria-label="Load"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon" class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
@ -48,61 +76,10 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
</svg> </svg>
</div> </div>
</button> </button>
<button
aria-label="Save"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="save-button"
title="Save"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
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"
fill="currentColor"
/>
</svg>
</div>
</button>
<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"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 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"
fill="currentColor"
/>
</svg>
</div>
</button>
<button <button
aria-label="Export" aria-label="Export"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon" class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="export-button" data-testid="json-export-button"
title="Export" title="Export"
type="button" type="button"
> >
@ -125,10 +102,10 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
</div> </div>
</button> </button>
<button <button
aria-label="Reset the canvas" aria-label="Save as image"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon" class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="clear-canvas-button" data-testid="image-export-button"
title="Reset the canvas" title="Save as image"
type="button" type="button"
> >
<div <div
@ -137,18 +114,25 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="" class="rtl-mirror"
focusable="false" focusable="false"
role="img" role="img"
viewBox="0 0 448 512" viewBox="0 0 576 512"
> >
<path <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" 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="currentColor" 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> </svg>
</div> </div>
</button> </button>
<div
style="width: .625em;"
/>
</div> </div>
<div <div
style="display: flex;" style="display: flex;"
@ -186,17 +170,15 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
<div <div
style="margin-inline-start: 0.25rem;" style="margin-inline-start: 0.25rem;"
> >
<label <button
class="ToolIcon ToolIcon_type_floating ToolIcon_size_M" aria-label="Dark mode"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon ToolIcon--plain"
data-testid="toggle-dark-mode" data-testid="toggle-dark-mode"
title="Dark mode" title="Dark mode"
type="button"
> >
<input
aria-label="Dark mode"
class="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
type="checkbox"
/>
<div <div
aria-hidden="true"
class="ToolIcon__icon" class="ToolIcon__icon"
> >
<svg <svg
@ -211,7 +193,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
/> />
</svg> </svg>
</div> </div>
</label> </button>
</div> </div>
</div> </div>
</div> </div>
@ -242,6 +224,34 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
class="Stack Stack_horizontal" class="Stack Stack_horizontal"
style="--gap: 1; justify-content: space-between;" style="--gap: 1; justify-content: space-between;"
> >
<button
aria-label="Reset the canvas"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 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"
/>
</svg>
</div>
</button>
<div
style="width: .625em;"
/>
<button <button
aria-label="Load" aria-label="Load"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon" class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
@ -267,61 +277,10 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
</svg> </svg>
</div> </div>
</button> </button>
<button
aria-label="Save"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="save-button"
title="Save"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
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"
fill="currentColor"
/>
</svg>
</div>
</button>
<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"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 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"
fill="currentColor"
/>
</svg>
</div>
</button>
<button <button
aria-label="Export" aria-label="Export"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon" class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="export-button" data-testid="json-export-button"
title="Export" title="Export"
type="button" type="button"
> >
@ -344,10 +303,10 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
</div> </div>
</button> </button>
<button <button
aria-label="Reset the canvas" aria-label="Save as image"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon" class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="clear-canvas-button" data-testid="image-export-button"
title="Reset the canvas" title="Save as image"
type="button" type="button"
> >
<div <div
@ -356,18 +315,25 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
> >
<svg <svg
aria-hidden="true" aria-hidden="true"
class="" class="rtl-mirror"
focusable="false" focusable="false"
role="img" role="img"
viewBox="0 0 448 512" viewBox="0 0 576 512"
> >
<path <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" 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="currentColor" 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> </svg>
</div> </div>
</button> </button>
<div
style="width: .625em;"
/>
</div> </div>
<div <div
style="display: flex;" style="display: flex;"
@ -405,17 +371,15 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
<div <div
style="margin-inline-start: 0.25rem;" style="margin-inline-start: 0.25rem;"
> >
<label <button
class="ToolIcon ToolIcon_type_floating ToolIcon_size_M" aria-label="Dark mode"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon ToolIcon--plain"
data-testid="toggle-dark-mode" data-testid="toggle-dark-mode"
title="Dark mode" title="Dark mode"
type="button"
> >
<input
aria-label="Dark mode"
class="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
type="checkbox"
/>
<div <div
aria-hidden="true"
class="ToolIcon__icon" class="ToolIcon__icon"
> >
<svg <svg
@ -430,7 +394,7 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
/> />
</svg> </svg>
</div> </div>
</label> </button>
</div> </div>
</div> </div>
</div> </div>

View File

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