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 }) => ( - + ), }); @@ -60,17 +59,15 @@ export const actionChangeExportEmbedScene = register({ }; }, PanelComponent: ({ appState, updateData }) => ( - + ), }); @@ -83,14 +80,12 @@ export const actionChangeShouldAddWatermark = register({ }; }, PanelComponent: ({ appState, updateData }) => ( - + ), }); @@ -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(); + }} + > + +
{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 ( - + 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 ( + + ); +}; + +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 && ( -
-
+
+ + {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 ( - <> -
+
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
- +
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 () => {