feat: update design of ImageExportDialog (#6614)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Are 2023-05-26 16:16:55 +02:00 committed by GitHub
parent 6459ccda6a
commit 08563e7d7b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 881 additions and 171 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

116
src/components/Switch.scss Normal file
View File

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

38
src/components/Switch.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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