feat: update design of ImageExportDialog (#6614)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
6459ccda6a
commit
08563e7d7b
@ -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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
@ -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);
|
||||
|
||||
|
@ -31,7 +31,7 @@ const ConfirmDialog = (props: Props) => {
|
||||
return (
|
||||
<Dialog
|
||||
onCloseRequest={onCancel}
|
||||
small={true}
|
||||
size="small"
|
||||
{...rest}
|
||||
className={`confirm-dialog ${className}`}
|
||||
>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -28,7 +28,7 @@ export const ErrorDialog = ({
|
||||
<>
|
||||
{modalIsShown && (
|
||||
<Dialog
|
||||
small
|
||||
size="small"
|
||||
onCloseRequest={handleClose}
|
||||
title={t("errorDialog.title")}
|
||||
>
|
||||
|
215
src/components/ImageExportDialog.scss
Normal file
215
src/components/ImageExportDialog.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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` }}
|
||||
|
@ -106,7 +106,7 @@ export const PasteChartDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
small
|
||||
size="small"
|
||||
onCloseRequest={handleClose}
|
||||
title={t("labels.pasteCharts")}
|
||||
className={"PasteChartDialog"}
|
||||
|
@ -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);
|
||||
|
100
src/components/RadioGroup.scss
Normal file
100
src/components/RadioGroup.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
42
src/components/RadioGroup.tsx
Normal file
42
src/components/RadioGroup.tsx
Normal 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
116
src/components/Switch.scss
Normal 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
38
src/components/Switch.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
);
|
||||
|
@ -180,7 +180,7 @@ const RoomDialog = ({
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
small
|
||||
size="small"
|
||||
onCloseRequest={handleClose}
|
||||
title={t("labels.liveCollaboration")}
|
||||
theme={theme}
|
||||
|
@ -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"
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user