diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 61f6933f..1ccc3b1e 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -11,7 +11,8 @@ import { t } from "../i18n"; import { useIsMobile } from "../components/App"; import { KEYS } from "../keys"; import { register } from "./register"; -import { supported } from "browser-fs-access"; +import { supported as fsSupported } from "browser-fs-access"; +import { CheckboxItem } from "../components/CheckboxItem"; export const actionChangeProjectName = register({ name: "changeProjectName", @@ -40,14 +41,12 @@ export const actionChangeExportBackground = register({ }; }, PanelComponent: ({ appState, updateData }) => ( - - updateData(event.target.checked)} - />{" "} + updateData(checked)} + > {t("labels.withBackground")} - + ), }); @@ -60,17 +59,15 @@ export const actionChangeExportEmbedScene = register({ }; }, PanelComponent: ({ appState, updateData }) => ( - - updateData(event.target.checked)} - />{" "} + updateData(checked)} + > {t("labels.exportEmbedScene")} - {questionCircle} + {questionCircle} - + ), }); @@ -83,14 +80,12 @@ export const actionChangeShouldAddWatermark = register({ }; }, PanelComponent: ({ appState, updateData }) => ( - - updateData(event.target.checked)} - />{" "} + updateData(checked)} + > {t("labels.addWatermark")} - + ), }); @@ -126,11 +121,10 @@ export const actionSaveScene = register({ event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey, PanelComponent: ({ updateData }) => ( updateData(null)} data-testid="save-button" /> @@ -162,7 +156,7 @@ export const actionSaveAsScene = register({ title={t("buttons.saveAs")} aria-label={t("buttons.saveAs")} showAriaLabel={useIsMobile()} - hidden={!supported} + hidden={!fsSupported} onClick={() => updateData(null)} data-testid="save-as-button" /> diff --git a/src/actions/types.ts b/src/actions/types.ts index 7187a0cc..88d548a4 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -131,4 +131,5 @@ export interface ActionsManagerInterface { registerAction: (action: Action) => void; handleKeyDown: (event: React.KeyboardEvent | KeyboardEvent) => boolean; renderAction: (name: ActionName) => React.ReactElement | null; + executeAction: (action: Action) => void; } diff --git a/src/components/App.tsx b/src/components/App.tsx index 22b8a2b8..6641f4c9 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -2,7 +2,7 @@ import React, { useContext } from "react"; import { RoughCanvas } from "roughjs/bin/canvas"; import rough from "roughjs/bin/rough"; import clsx from "clsx"; -import { supported } from "browser-fs-access"; +import { supported as fsSupported } from "browser-fs-access"; import { nanoid } from "nanoid"; import { @@ -3885,7 +3885,7 @@ class App extends React.Component { // default: assume an Excalidraw file regardless of extension/MimeType } else { this.setState({ isLoading: true }); - if (supported) { + if (fsSupported) { try { // This will only work as of Chrome 86, // but can be safely ignored on older releases. diff --git a/src/components/BackgroundPickerAndDarkModeToggle.tsx b/src/components/BackgroundPickerAndDarkModeToggle.tsx index e6a04880..39185b4c 100644 --- a/src/components/BackgroundPickerAndDarkModeToggle.tsx +++ b/src/components/BackgroundPickerAndDarkModeToggle.tsx @@ -15,6 +15,11 @@ export const BackgroundPickerAndDarkModeToggle = ({ }) => ( {actionManager.renderAction("changeViewBackgroundColor")} - {showThemeBtn && <>{actionManager.renderAction("toggleTheme")}>} + {showThemeBtn && actionManager.renderAction("toggleTheme")} + {appState.fileHandle && ( + + {actionManager.renderAction("saveScene")} + + )} ); diff --git a/src/components/Card.scss b/src/components/Card.scss new file mode 100644 index 00000000..4d1309ad --- /dev/null +++ b/src/components/Card.scss @@ -0,0 +1,53 @@ +@import "../css/variables.module"; + +.excalidraw { + .Card { + display: flex; + flex-direction: column; + align-items: center; + + max-width: 290px; + + margin: 1em; + + text-align: center; + + .Card-icon { + font-size: 2.6em; + display: flex; + flex: 0 0 auto; + padding: 1.4rem; + border-radius: 50%; + background: var(--card-color); + color: $oc-white; + + svg { + width: 2.8rem; + height: 2.8rem; + } + } + + .Card-details { + font-size: 0.96em; + min-height: 90px; + padding: 0 1em; + margin-bottom: auto; + } + + & .Card-button.ToolIcon_type_button { + height: 2.5rem; + margin-top: 1em; + margin-bottom: 0.3em; + background-color: var(--card-color); + &:hover { + background-color: var(--card-color-darker); + } + &:active { + background-color: var(--card-color-darkest); + } + .ToolIcon__label { + color: $oc-white; + } + } + } +} diff --git a/src/components/Card.tsx b/src/components/Card.tsx new file mode 100644 index 00000000..c5315237 --- /dev/null +++ b/src/components/Card.tsx @@ -0,0 +1,20 @@ +import OpenColor from "open-color"; + +import "./Card.scss"; + +export const Card: React.FC<{ + color: keyof OpenColor; +}> = ({ children, color }) => { + return ( + + {children} + + ); +}; diff --git a/src/components/CheckboxItem.scss b/src/components/CheckboxItem.scss new file mode 100644 index 00000000..341c1561 --- /dev/null +++ b/src/components/CheckboxItem.scss @@ -0,0 +1,85 @@ +@import "../css/variables.module"; + +.excalidraw { + .Checkbox { + margin: 3px 0.3em; + display: flex; + align-items: center; + + cursor: pointer; + user-select: none; + + &:hover:not(.is-checked) .Checkbox-box { + box-shadow: 0 0 0 2px #{$oc-blue-4}; + + svg { + display: block; + opacity: 0.3; + } + } + + &:active { + .Checkbox-box { + box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important; + } + } + + &:hover { + .Checkbox-box { + background-color: fade-out($oc-blue-1, 0.8); + } + } + + &.is-checked { + .Checkbox-box { + background-color: #{$oc-blue-1}; + svg { + display: block; + } + } + &:hover .Checkbox-box { + background-color: #{$oc-blue-2}; + } + } + + .Checkbox-box { + width: 22px; + height: 22px; + padding: 0; + flex: 0 0 auto; + + margin: 0 1em; + + display: flex; + align-items: center; + justify-content: center; + + box-shadow: 0 0 0 2px #{$oc-blue-7}; + background-color: transparent; + border-radius: 4px; + + color: #{$oc-blue-7}; + + &:focus { + box-shadow: 0 0 0 3px #{$oc-blue-7}; + } + + svg { + display: none; + width: 16px; + height: 16px; + stroke-width: 3px; + } + } + + .Checkbox-label { + display: flex; + align-items: center; + } + + .Tooltip-icon { + width: 1em; + height: 1em; + } + } +} diff --git a/src/components/CheckboxItem.tsx b/src/components/CheckboxItem.tsx new file mode 100644 index 00000000..0aa6f23f --- /dev/null +++ b/src/components/CheckboxItem.tsx @@ -0,0 +1,26 @@ +import clsx from "clsx"; +import { checkIcon } from "./icons"; + +import "./CheckboxItem.scss"; + +export const CheckboxItem: React.FC<{ + checked: boolean; + onChange: (checked: boolean) => void; +}> = ({ children, checked, onChange }) => { + return ( + { + onChange(!checked); + ((event.currentTarget as HTMLDivElement).querySelector( + ".Checkbox-box", + ) as HTMLButtonElement).focus(); + }} + > + + {checkIcon} + + {children} + + ); +}; diff --git a/src/components/ColorPicker.scss b/src/components/ColorPicker.scss index 0f672b53..c5a178d3 100644 --- a/src/components/ColorPicker.scss +++ b/src/components/ColorPicker.scss @@ -160,7 +160,7 @@ } .color-picker-input { - width: 12ch; /* length of `transparent` + 1 */ + width: 11ch; /* length of `transparent` */ margin: 0; font-size: 1rem; background-color: var(--input-bg-color); diff --git a/src/components/DarkModeToggle.tsx b/src/components/DarkModeToggle.tsx index bb649870..a22c9a14 100644 --- a/src/components/DarkModeToggle.tsx +++ b/src/components/DarkModeToggle.tsx @@ -2,6 +2,7 @@ import "./ToolIcon.scss"; import React from "react"; import { t } from "../i18n"; +import { ToolButton } from "./ToolButton"; export type Appearence = "light" | "dark"; @@ -12,31 +13,19 @@ export const DarkModeToggle = (props: { onChange: (value: Appearence) => void; title?: string; }) => { - const title = props.title - ? props.title - : props.value === "dark" - ? t("buttons.lightMode") - : t("buttons.darkMode"); + const title = + props.title || + (props.value === "dark" ? t("buttons.lightMode") : t("buttons.darkMode")); return ( - - - props.onChange(event.target.checked ? "dark" : "light") - } - checked={props.value === "dark"} - aria-label={title} - /> - - {props.value === "light" ? ICONS.MOON : ICONS.SUN} - - + aria-label={title} + onClick={() => props.onChange(props.value === "dark" ? "light" : "dark")} + data-testid="toggle-dark-mode" + /> ); }; diff --git a/src/components/ExportDialog.scss b/src/components/ExportDialog.scss index 541c17e6..fd847301 100644 --- a/src/components/ExportDialog.scss +++ b/src/components/ExportDialog.scss @@ -28,33 +28,6 @@ justify-content: space-between; } - .ExportDialog__name { - grid-column: project-name; - margin: auto; - display: flex; - align-items: center; - - .TextInput { - height: calc(1rem - 3px); - width: 200px; - overflow: hidden; - text-align: center; - margin-left: 8px; - text-overflow: ellipsis; - - &--readonly { - background: none; - border: none; - &:hover { - background: none; - } - width: auto; - max-width: 200px; - padding-left: 2px; - } - } - } - @include isMobile { .ExportDialog { display: flex; @@ -84,4 +57,62 @@ overflow-y: auto; } } + + .ExportDialog--json { + .ExportDialog-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + justify-items: center; + row-gap: 2em; + + @media (max-width: 460px) { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + .Card-details { + min-height: 40px; + } + } + + .ProjectName { + width: fit-content; + margin: 1em auto; + align-items: flex-start; + flex-direction: column; + + .TextInput { + width: auto; + } + } + + .ProjectName-label { + margin: 0.625em 0; + font-weight: bold; + } + } + } + + button.ExportDialog-imageExportButton { + width: 5rem; + height: 5rem; + margin: 0 0.2em; + + border-radius: 1rem; + background-color: var(--button-color); + box-shadow: 0 3px 5px -1px rgb(0 0 0 / 28%), 0 6px 10px 0 rgb(0 0 0 / 14%); + + font-family: Cascadia; + font-size: 1.8em; + color: $oc-white; + + &:hover { + background-color: var(--button-color-darker); + } + &:active { + background-color: var(--button-color-darkest); + box-shadow: 0 3px 5px -1px rgb(0 0 0 / 20%), 0 6px 10px 0 rgb(0 0 0 / 14%); + } + + svg { + width: 0.9em; + } + } } diff --git a/src/components/ExportDialog.tsx b/src/components/ImageExportDialog.tsx similarity index 55% rename from src/components/ExportDialog.tsx rename to src/components/ImageExportDialog.tsx index 23abb8c6..ea279e2e 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ImageExportDialog.tsx @@ -6,16 +6,20 @@ import { canvasToBlob } from "../data/blob"; import { NonDeletedExcalidrawElement } from "../element/types"; import { CanvasError } from "../errors"; import { t } from "../i18n"; -import { useIsMobile } from "../components/App"; +import { useIsMobile } from "./App"; import { getSelectedElements, isSomeElementSelected } from "../scene"; import { exportToCanvas, getExportSize } from "../scene/export"; import { AppState } from "../types"; import { Dialog } from "./Dialog"; -import "./ExportDialog.scss"; -import { clipboard, exportFile, link } from "./icons"; +import { clipboard, exportImage } from "./icons"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; +import "./ExportDialog.scss"; +import { supported as fsSupported } from "browser-fs-access"; +import OpenColor from "open-color"; +import { CheckboxItem } from "./CheckboxItem"; + const scales = [1, 2, 3]; const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1; @@ -52,7 +56,30 @@ export type ExportCB = ( scale?: number, ) => void; -const ExportModal = ({ +const ExportButton: React.FC<{ + color: keyof OpenColor; + onClick: () => void; + title: string; + shade?: number; +}> = ({ children, title, onClick, color, shade = 6 }) => { + return ( + + {children} + + ); +}; + +const ImageExportModal = ({ elements, appState, exportPadding = 10, @@ -60,7 +87,6 @@ const ExportModal = ({ onExportToPng, onExportToSvg, onExportToClipboard, - onExportToBackend, }: { appState: AppState; elements: readonly NonDeletedExcalidrawElement[]; @@ -69,7 +95,6 @@ const ExportModal = ({ onExportToPng: ExportCB; onExportToSvg: ExportCB; onExportToClipboard: ExportCB; - onExportToBackend?: ExportCB; onCloseRequest: () => void; }) => { const someElementIsSelected = isSomeElementSelected(elements, appState); @@ -133,98 +158,103 @@ const ExportModal = ({ {supportsContextFilters && actionManager.renderAction("exportWithDarkMode")} - - - - onExportToPng(exportedElements, scale)} - /> - onExportToSvg(exportedElements, scale)} - /> - {probablySupportsClipboardBlob && ( - onExportToClipboard(exportedElements, scale)} - /> - )} - {onExportToBackend && ( - onExportToBackend(exportedElements)} - /> - )} - - - {actionManager.renderAction("changeProjectName")} - - - {scales.map((s) => { - const [width, height] = getExportSize( - exportedElements, - exportPadding, - shouldAddWatermark, - s, - ); - - const scaleButtonTitle = `${t( - "buttons.scale", - )} ${s}x (${width}x${height})`; - - return ( - setScale(s)} - /> - ); - })} - - - {actionManager.renderAction("changeExportBackground")} - {someElementIsSelected && ( - - - - setExportSelected(event.currentTarget.checked) - } - />{" "} + + + {actionManager.renderAction("changeExportBackground")} + {someElementIsSelected && ( + setExportSelected(checked)} + > {t("labels.onlySelected")} - - + + )} + {actionManager.renderAction("changeExportEmbedScene")} + + + + + {scales.map((_scale) => { + const [width, height] = getExportSize( + exportedElements, + exportPadding, + shouldAddWatermark, + _scale, + ); + + const scaleButtonTitle = `${t( + "buttons.scale", + )} ${_scale}x (${width}x${height})`; + + return ( + setScale(_scale)} + /> + ); + })} + + Scale + + + {!fsSupported && actionManager.renderAction("changeProjectName")} + + + onExportToPng(exportedElements, scale)} + > + PNG + + onExportToSvg(exportedElements, scale)} + > + SVG + + {probablySupportsClipboardBlob && ( + onExportToClipboard(exportedElements, scale)} + color="gray" + shade={7} + > + {clipboard} + )} - {actionManager.renderAction("changeExportEmbedScene")} - {actionManager.renderAction("changeShouldAddWatermark")} - + ); }; -export const ExportDialog = ({ +export const ImageExportDialog = ({ elements, appState, exportPadding = 10, @@ -232,7 +262,6 @@ export const ExportDialog = ({ onExportToPng, onExportToSvg, onExportToClipboard, - onExportToBackend, }: { appState: AppState; elements: readonly NonDeletedExcalidrawElement[]; @@ -241,7 +270,6 @@ export const ExportDialog = ({ onExportToPng: ExportCB; onExportToSvg: ExportCB; onExportToClipboard: ExportCB; - onExportToBackend?: ExportCB; }) => { const [modalIsShown, setModalIsShown] = useState(false); @@ -255,16 +283,16 @@ export const ExportDialog = ({ onClick={() => { setModalIsShown(true); }} - data-testid="export-button" - icon={exportFile} + data-testid="image-export-button" + icon={exportImage} type="button" - aria-label={t("buttons.export")} + aria-label={t("buttons.exportImage")} showAriaLabel={useIsMobile()} - title={t("buttons.export")} + title={t("buttons.exportImage")} /> {modalIsShown && ( - - + diff --git a/src/components/JSONExportDialog.tsx b/src/components/JSONExportDialog.tsx new file mode 100644 index 00000000..c878c8e7 --- /dev/null +++ b/src/components/JSONExportDialog.tsx @@ -0,0 +1,117 @@ +import React, { useState } from "react"; +import { ActionsManagerInterface } from "../actions/types"; +import { NonDeletedExcalidrawElement } from "../element/types"; +import { t } from "../i18n"; +import { useIsMobile } from "./App"; +import { AppState } from "../types"; +import { Dialog } from "./Dialog"; +import { exportFile, exportToFileIcon, link } from "./icons"; +import { ToolButton } from "./ToolButton"; +import { actionSaveAsScene } from "../actions/actionExport"; +import { Card } from "./Card"; + +import "./ExportDialog.scss"; +import { supported as fsSupported } from "browser-fs-access"; + +export type ExportCB = ( + elements: readonly NonDeletedExcalidrawElement[], + scale?: number, +) => void; + +const JSONExportModal = ({ + elements, + appState, + actionManager, + onExportToBackend, +}: { + appState: AppState; + elements: readonly NonDeletedExcalidrawElement[]; + actionManager: ActionsManagerInterface; + onExportToBackend?: ExportCB; + onCloseRequest: () => void; +}) => { + return ( + + + + {exportToFileIcon} + {t("exportDialog.disk_title")} + + {t("exportDialog.disk_details")} + {!fsSupported && actionManager.renderAction("changeProjectName")} + + { + actionManager.executeAction(actionSaveAsScene); + }} + /> + + {onExportToBackend && ( + + {link} + {t("exportDialog.link_title")} + {t("exportDialog.link_details")} + onExportToBackend(elements)} + /> + + )} + + + ); +}; + +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 ( + <> + { + setModalIsShown(true); + }} + data-testid="json-export-button" + icon={exportFile} + type="button" + aria-label={t("buttons.export")} + showAriaLabel={useIsMobile()} + title={t("buttons.export")} + /> + {modalIsShown && ( + + + + )} + > + ); +}; diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index a354322c..3c2fd33a 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -28,7 +28,7 @@ import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions"; import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; import CollabButton from "./CollabButton"; import { ErrorDialog } from "./ErrorDialog"; -import { ExportCB, ExportDialog } from "./ExportDialog"; +import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; import { FixedSideContainer } from "./FixedSideContainer"; import { HintViewer } from "./HintViewer"; import { exportFile, load, trash } from "./icons"; @@ -46,6 +46,7 @@ import { ToolButton } from "./ToolButton"; import { Tooltip } from "./Tooltip"; import { UserList } from "./UserList"; import Library from "../data/library"; +import { JSONExportDialog } from "./JSONExportDialog"; interface LayerUIProps { actionManager: ActionManager; @@ -382,7 +383,29 @@ const LayerUI = ({ }: LayerUIProps) => { const isMobile = useIsMobile(); - const renderExportDialog = () => { + const renderJSONExportDialog = () => { + if (!UIOptions.canvasActions.export) { + return null; + } + + return ( + { + onExportToBackend && + onExportToBackend(elements, appState, canvas); + } + : undefined + } + /> + ); + }; + + const renderImageExportDialog = () => { if (!UIOptions.canvasActions.export) { return null; } @@ -406,25 +429,21 @@ const LayerUI = ({ }; return ( - { - onExportToBackend && - onExportToBackend(elements, appState, canvas); - } - : undefined - } /> ); }; + const Separator = () => { + return ; + }; + const renderViewModeCanvasActions = () => { return ( - {actionManager.renderAction("saveScene")} - {actionManager.renderAction("saveAsScene")} - {renderExportDialog()} + {renderJSONExportDialog()} + {renderImageExportDialog()} @@ -459,11 +477,12 @@ const LayerUI = ({ - {actionManager.renderAction("loadScene")} - {actionManager.renderAction("saveScene")} - {actionManager.renderAction("saveAsScene")} - {renderExportDialog()} {actionManager.renderAction("clearCanvas")} + + {actionManager.renderAction("loadScene")} + {renderJSONExportDialog()} + {renderImageExportDialog()} + {onCollabButtonClick && ( React.ReactNode; + renderImageExportDialog: () => React.ReactNode; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; libraryMenu: JSX.Element | null; @@ -38,7 +39,8 @@ export const MobileMenu = ({ elements, libraryMenu, actionManager, - exportButton, + renderJSONExportDialog, + renderImageExportDialog, setAppState, onCollabButtonClick, onLockToggle, @@ -107,19 +109,17 @@ export const MobileMenu = ({ if (viewModeEnabled) { return ( <> - {actionManager.renderAction("saveScene")} - {actionManager.renderAction("saveAsScene")} - {exportButton} + {renderJSONExportDialog()} + {renderImageExportDialog()} > ); } return ( <> - {actionManager.renderAction("loadScene")} - {actionManager.renderAction("saveScene")} - {actionManager.renderAction("saveAsScene")} - {exportButton} {actionManager.renderAction("clearCanvas")} + {actionManager.renderAction("loadScene")} + {renderJSONExportDialog()} + {renderImageExportDialog()} {onCollabButtonClick && ( void; @@ -37,8 +39,8 @@ export class ProjectName extends Component { public render() { return ( - <> - + + {`${this.props.label}${this.props.isNameEditable ? "" : ":"}`} {this.props.isNameEditable ? ( @@ -46,18 +48,18 @@ export class ProjectName extends Component { className="TextInput" onBlur={this.handleBlur} onKeyDown={this.handleKeyDown} - id="file-name" + id="filename" value={this.state.fileName} onChange={(event) => this.setState({ fileName: event.target.value }) } /> ) : ( - + {this.props.value} )} - > + ); } } diff --git a/src/components/Stats.scss b/src/components/Stats.scss index 4884c368..72acd266 100644 --- a/src/components/Stats.scss +++ b/src/components/Stats.scss @@ -6,7 +6,7 @@ top: 64px; right: 12px; font-size: 12px; - z-index: 999; + z-index: 10; h3 { margin: 0 24px 8px 0; diff --git a/src/components/ToolButton.tsx b/src/components/ToolButton.tsx index 68db6dcf..c2ed29d6 100644 --- a/src/components/ToolButton.tsx +++ b/src/components/ToolButton.tsx @@ -29,9 +29,13 @@ type ToolButtonProps = children?: React.ReactNode; onClick?(): void; }) + | (ToolButtonBaseProps & { + type: "icon"; + children?: React.ReactNode; + onClick?(): void; + }) | (ToolButtonBaseProps & { type: "radio"; - checked: boolean; onChange?(): void; }); @@ -43,7 +47,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { React.useImperativeHandle(ref, () => innerRef.current); const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`; - if (props.type === "button") { + if (props.type === "button" || props.type === "icon") { return ( { { ToolIcon: !props.hidden, "ToolIcon--selected": props.selected, + "ToolIcon--plain": props.type === "icon", }, )} data-testid={props["data-testid"]} @@ -66,14 +71,16 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { onClick={props.onClick} ref={innerRef} > - - {props.icon || props.label} - {props.keyBindingLabel && ( - - {props.keyBindingLabel} - - )} - + {(props.icon || props.label) && ( + + {props.icon || props.label} + {props.keyBindingLabel && ( + + {props.keyBindingLabel} + + )} + + )} {props.showAriaLabel && ( {props["aria-label"]} )} diff --git a/src/components/ToolIcon.scss b/src/components/ToolIcon.scss index 3d3f37d1..faecf65f 100644 --- a/src/components/ToolIcon.scss +++ b/src/components/ToolIcon.scss @@ -11,6 +11,15 @@ background-color: var(--button-gray-1); -webkit-tap-highlight-color: transparent; border-radius: var(--space-factor); + user-select: none; + } + + .ToolIcon--plain { + background-color: transparent; + .ToolIcon__icon { + width: 2rem; + height: 2rem; + } } .ToolIcon__icon { @@ -187,17 +196,6 @@ } } - .TooltipIcon { - width: 0.9em; - height: 0.9em; - margin-left: 5px; - margin-top: 1px; - - @include isMobile { - display: none; - } - } - .unlocked-icon { :root[dir="ltr"] & { left: 2px; diff --git a/src/components/Tooltip.scss b/src/components/Tooltip.scss index e9c32452..178a6fb8 100644 --- a/src/components/Tooltip.scss +++ b/src/components/Tooltip.scss @@ -23,3 +23,17 @@ display: block; } } + +.excalidraw { + .Tooltip-icon { + width: 0.9em; + height: 0.9em; + margin-left: 5px; + margin-top: 1px; + display: flex; + + @include isMobile { + display: none; + } + } +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 2c9debfd..2d99fd67 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -41,6 +41,14 @@ const createIcon = (d: string | React.ReactNode, opts: number | Opts = 512) => { ); }; +export const checkIcon = createIcon( + , + { + width: 24, + height: 24, + }, +); + export const link = createIcon( "M326.612 185.391c59.747 59.809 58.927 155.698.36 214.59-.11.12-.24.25-.36.37l-67.2 67.2c-59.27 59.27-155.699 59.262-214.96 0-59.27-59.26-59.27-155.7 0-214.96l37.106-37.106c9.84-9.84 26.786-3.3 27.294 10.606.648 17.722 3.826 35.527 9.69 52.721 1.986 5.822.567 12.262-3.783 16.612l-13.087 13.087c-28.026 28.026-28.905 73.66-1.155 101.96 28.024 28.579 74.086 28.749 102.325.51l67.2-67.19c28.191-28.191 28.073-73.757 0-101.83-3.701-3.694-7.429-6.564-10.341-8.569a16.037 16.037 0 0 1-6.947-12.606c-.396-10.567 3.348-21.456 11.698-29.806l21.054-21.055c5.521-5.521 14.182-6.199 20.584-1.731a152.482 152.482 0 0 1 20.522 17.197zM467.547 44.449c-59.261-59.262-155.69-59.27-214.96 0l-67.2 67.2c-.12.12-.25.25-.36.37-58.566 58.892-59.387 154.781.36 214.59a152.454 152.454 0 0 0 20.521 17.196c6.402 4.468 15.064 3.789 20.584-1.731l21.054-21.055c8.35-8.35 12.094-19.239 11.698-29.806a16.037 16.037 0 0 0-6.947-12.606c-2.912-2.005-6.64-4.875-10.341-8.569-28.073-28.073-28.191-73.639 0-101.83l67.2-67.19c28.239-28.239 74.3-28.069 102.325.51 27.75 28.3 26.872 73.934-1.155 101.96l-13.087 13.087c-4.35 4.35-5.769 10.79-3.783 16.612 5.864 17.194 9.042 34.999 9.69 52.721.509 13.906 17.454 20.446 27.294 10.606l37.106-37.106c59.271-59.259 59.271-155.699.001-214.959z", { mirror: true }, @@ -80,6 +88,25 @@ export const exportFile = createIcon( { width: 576, height: 512, mirror: true }, ); +export const exportImage = createIcon( + <> + + + >, + { width: 576, height: 512, mirror: true }, +); + +export const exportToFileIcon = createIcon( + "M216 0h80c13.3 0 24 10.7 24 24v168h87.7c17.8 0 26.7 21.5 14.1 34.1L269.7 378.3c-7.5 7.5-19.8 7.5-27.3 0L90.1 226.1c-12.6-12.6-3.7-34.1 14.1-34.1H192V24c0-13.3 10.7-24 24-24zm296 376v112c0 13.3-10.7 24-24 24H24c-13.3 0-24-10.7-24-24V376c0-13.3 10.7-24 24-24h146.7l49 49c20.1 20.1 52.5 20.1 72.6 0l49-49H488c13.3 0 24 10.7 24 24zm-124 88c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20zm64 0c0-11-9-20-20-20s-20 9-20 20 9 20 20 20 20-9 20-20z", + { width: 512, height: 512 }, +); + export const zoomIn = createIcon( "M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z", { width: 448, height: 512 }, @@ -350,14 +377,6 @@ export const DistributeHorizontallyIcon = React.memo( ), ); -; - export const DistributeVerticallyIcon = React.memo( ({ theme }: { theme: "light" | "dark" }) => createIcon( diff --git a/src/locales/en.json b/src/locales/en.json index e4ccf132..f287c432 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -42,8 +42,8 @@ "fontSize": "Font size", "fontFamily": "Font family", "onlySelected": "Only selected", - "withBackground": "With background", - "exportEmbedScene": "Embed scene into exported file", + "withBackground": "Background", + "exportEmbedScene": "Embed scene", "exportEmbedScene_details": "Scene data will be saved into the exported PNG/SVG file so that the scene can be restored from it.\nWill increase exported file size.", "addWatermark": "Add \"Made with Excalidraw\"", "handDrawn": "Hand-drawn", @@ -105,13 +105,15 @@ }, "buttons": { "clearReset": "Reset the canvas", + "exportJSON": "Export to file", + "exportImage": "Save as image", "export": "Export", "exportToPng": "Export to PNG", "exportToSvg": "Export to SVG", "copyToClipboard": "Copy to clipboard", "copyPngToClipboard": "Copy PNG to clipboard", "scale": "Scale", - "save": "Save", + "save": "Save to current file", "saveAs": "Save as", "load": "Load", "getShareableLink": "Get shareable link", @@ -215,6 +217,14 @@ "errorDialog": { "title": "Error" }, + "exportDialog": { + "disk_title": "Save to disk", + "disk_details": "Export the scene data to a file from which you can import later.", + "disk_button": "Save to file", + "link_title": "Shareable link", + "link_details": "Export as a read-only link.", + "link_button": "Export to Link" + }, "helpDialog": { "blog": "Read our blog", "click": "click", diff --git a/src/tests/__snapshots__/excalidrawPackage.test.tsx.snap b/src/tests/__snapshots__/excalidrawPackage.test.tsx.snap index e4f48689..4ec188cb 100644 --- a/src/tests/__snapshots__/excalidrawPackage.test.tsx.snap +++ b/src/tests/__snapshots__/excalidrawPackage.test.tsx.snap @@ -23,6 +23,34 @@ exports[` Test UIOptions prop Test canvasActions should not hide an class="Stack Stack_horizontal" style="--gap: 1; justify-content: space-between;" > + + + + + + + + Test UIOptions prop Test canvasActions should not hide an - - - - - - - - - - - - - - @@ -125,10 +102,10 @@ exports[` Test UIOptions prop Test canvasActions should not hide an Test UIOptions prop Test canvasActions should not hide an > + + Test UIOptions prop Test canvasActions should not hide an - - Test UIOptions prop Test canvasActions should not hide an /> - + @@ -242,6 +224,34 @@ exports[` Test UIOptions prop should not hide any UI element when t class="Stack Stack_horizontal" style="--gap: 1; justify-content: space-between;" > + + + + + + + + Test UIOptions prop should not hide any UI element when t - - - - - - - - - - - - - - @@ -344,10 +303,10 @@ exports[` Test UIOptions prop should not hide any UI element when t Test UIOptions prop should not hide any UI element when t > + + Test UIOptions prop should not hide any UI element when t - - Test UIOptions prop should not hide any UI element when t /> - + diff --git a/src/tests/excalidrawPackage.test.tsx b/src/tests/excalidrawPackage.test.tsx index f417630f..83225226 100644 --- a/src/tests/excalidrawPackage.test.tsx +++ b/src/tests/excalidrawPackage.test.tsx @@ -110,9 +110,9 @@ describe("", () => { it('should allow editing name when the name prop is "undefined"', async () => { const { container } = await render(); - fireEvent.click(queryByTestId(container, "export-button")!); + fireEvent.click(queryByTestId(container, "image-export-button")!); const textInput: HTMLInputElement | null = document.querySelector( - ".ExportDialog__name .TextInput", + ".ExportDialog .ProjectName .TextInput", ); expect(textInput?.value).toContain(`${t("labels.untitled")}`); expect(textInput?.nodeName).toBe("INPUT"); @@ -122,9 +122,9 @@ describe("", () => { const name = "test"; const { container } = await render(); - await fireEvent.click(queryByTestId(container, "export-button")!); + await fireEvent.click(queryByTestId(container, "image-export-button")!); const textInput = document.querySelector( - ".ExportDialog__name .TextInput--readonly", + ".ExportDialog .ProjectName .TextInput--readonly", ); expect(textInput?.textContent).toEqual(name); expect(textInput?.nodeName).toBe("SPAN"); @@ -166,7 +166,8 @@ describe("", () => { , ); - 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 () => {
Scale