feat: customise export dialog with UIOptions.canvasActions.export prop (#3658)

* refactor: update UIOptions.canvasActions.export to be a an object

* fix

* fix

* dnt show export icon when false

* fix

* inline

* memoize UIOptions

* update docs

* fix

* tweak readme

Co-authored-by: David Luzar <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2021-05-29 02:56:25 +05:30 committed by GitHub
parent 6c3e4417e1
commit ba48974351
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 140 additions and 68 deletions

View File

@ -113,8 +113,8 @@ export const actionSaveToActiveFile = register({
),
});
export const actionSaveAsScene = register({
name: "saveAsScene",
export const actionSaveFileToDisk = register({
name: "saveFileToDisk",
perform: async (elements, appState, value) => {
try {
const { fileHandle } = await saveAsJSON(elements, {

View File

@ -35,7 +35,7 @@ export {
actionChangeProjectName,
actionChangeExportBackground,
actionSaveToActiveFile,
actionSaveAsScene,
actionSaveFileToDisk,
actionLoadScene,
} from "./actionExport";

View File

@ -67,7 +67,7 @@ export type ActionName =
| "changeExportBackground"
| "changeExportEmbedScene"
| "saveToActiveFile"
| "saveAsScene"
| "saveFileToDisk"
| "loadScene"
| "duplicateSelection"
| "deleteSelectedElements"

View File

@ -452,7 +452,6 @@ class App extends React.Component<AppProps, AppState> {
const {
onCollabButtonClick,
onExportToBackend,
renderTopRightUI,
renderFooter,
renderCustomStats,
@ -493,7 +492,6 @@ class App extends React.Component<AppProps, AppState> {
toggleZenMode={this.toggleZenMode}
langCode={getLanguage().code}
isCollaborating={this.props.isCollaborating || false}
onExportToBackend={onExportToBackend}
renderTopRightUI={renderTopRightUI}
renderCustomFooter={renderFooter}
viewModeEnabled={viewModeEnabled}

View File

@ -3,11 +3,11 @@ import { ActionsManagerInterface } from "../actions/types";
import { NonDeletedExcalidrawElement } from "../element/types";
import { t } from "../i18n";
import { useIsMobile } from "./App";
import { AppState } from "../types";
import { AppState, ExportOpts } from "../types";
import { Dialog } from "./Dialog";
import { exportFile, exportToFileIcon, link } from "./icons";
import { ToolButton } from "./ToolButton";
import { actionSaveAsScene } from "../actions/actionExport";
import { actionSaveFileToDisk } from "../actions/actionExport";
import { Card } from "./Card";
import "./ExportDialog.scss";
@ -23,35 +23,39 @@ const JSONExportModal = ({
appState,
actionManager,
onExportToBackend,
exportOpts,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onExportToBackend?: ExportCB;
onCloseRequest: () => void;
exportOpts: ExportOpts;
}) => {
return (
<div className="ExportDialog ExportDialog--json">
<div className="ExportDialog-cards">
<Card color="lime">
<div className="Card-icon">{exportToFileIcon}</div>
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!fsSupported && actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.disk_button")}
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveAsScene);
}}
/>
</Card>
{onExportToBackend && (
{exportOpts.saveFileToDisk && (
<Card color="lime">
<div className="Card-icon">{exportToFileIcon}</div>
<h2>{t("exportDialog.disk_title")}</h2>
<div className="Card-details">
{t("exportDialog.disk_details")}
{!fsSupported && actionManager.renderAction("changeProjectName")}
</div>
<ToolButton
className="Card-button"
type="button"
title={t("exportDialog.disk_button")}
aria-label={t("exportDialog.disk_button")}
showAriaLabel={true}
onClick={() => {
actionManager.executeAction(actionSaveFileToDisk);
}}
/>
</Card>
)}
{exportOpts.onExportToBackend && (
<Card color="pink">
<div className="Card-icon">{link}</div>
<h2>{t("exportDialog.link_title")}</h2>
@ -62,7 +66,7 @@ const JSONExportModal = ({
title={t("exportDialog.link_button")}
aria-label={t("exportDialog.link_button")}
showAriaLabel={true}
onClick={() => onExportToBackend(elements)}
onClick={() => onExportToBackend!(elements)}
/>
</Card>
)}
@ -76,11 +80,13 @@ export const JSONExportDialog = ({
appState,
actionManager,
onExportToBackend,
exportOpts,
}: {
appState: AppState;
elements: readonly NonDeletedExcalidrawElement[];
actionManager: ActionsManagerInterface;
onExportToBackend?: ExportCB;
exportOpts: ExportOpts;
}) => {
const [modalIsShown, setModalIsShown] = useState(false);
@ -109,6 +115,7 @@ export const JSONExportDialog = ({
actionManager={actionManager}
onExportToBackend={onExportToBackend}
onCloseRequest={handleClose}
exportOpts={exportOpts}
/>
</Dialog>
)}

View File

@ -63,11 +63,6 @@ interface LayerUIProps {
toggleZenMode: () => void;
langCode: Language["code"];
isCollaborating: boolean;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
renderTopRightUI?: (isMobile: boolean, appState: AppState) => JSX.Element;
renderCustomFooter?: (isMobile: boolean, appState: AppState) => JSX.Element;
viewModeEnabled: boolean;
@ -371,7 +366,6 @@ const LayerUI = ({
showThemeBtn,
toggleZenMode,
isCollaborating,
onExportToBackend,
renderTopRightUI,
renderCustomFooter,
viewModeEnabled,
@ -393,14 +387,15 @@ const LayerUI = ({
elements={elements}
appState={appState}
actionManager={actionManager}
onExportToBackend={
onExportToBackend
? (elements) => {
onExportToBackend &&
onExportToBackend(elements, appState, canvas);
}
: undefined
}
onExportToBackend={(elements) => {
UIOptions.canvasActions.export.onExportToBackend &&
UIOptions.canvasActions.export.onExportToBackend(
elements,
appState,
canvas,
);
}}
exportOpts={UIOptions.canvasActions.export}
/>
);
};

View File

@ -131,9 +131,8 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: true,
export: { saveFileToDisk: true },
loadScene: true,
saveAsScene: true,
saveToActiveFile: true,
theme: true,
},

View File

@ -424,7 +424,11 @@ const ExcalidrawWrapper = () => {
onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating()}
onPointerUpdate={collabAPI?.onPointerUpdate}
onExportToBackend={onExportToBackend}
UIOptions={{
canvasActions: {
export: { onExportToBackend },
},
}}
renderTopRightUI={renderTopRightUI}
renderFooter={renderFooter}
langCode={langCode}

View File

@ -15,6 +15,17 @@ Please add the latest change on the top under the correct section.
## Excalidraw API
### Features
- Export dialog can be customised with [`UiOptions.canvasActions.export`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#exportOpts) [#3658](https://github.com/excalidraw/excalidraw/pull/3658).
Also, [`UIOptions`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#UIOptions) is now memoized to avoid unnecessary rerenders.
#### BREAKING CHANGE
- `UIOptions.canvasActions.saveAsScene` is now renamed to `UiOptions.canvasActions.export.saveFileToDisk`. Defaults to `true` hence the **save file to disk** button is rendered inside the export dialog.
- `exportToBackend` is now renamed to `UIOptions.canvasActions.export.exportToBackend`. If this prop is not passed, the **shareable-link** button will not be rendered, same as before.
### Refactor
- #### BREAKING CHANGE

View File

@ -363,7 +363,6 @@ To view the full example visit :point_down:
| [`onCollabButtonClick`](#onCollabButtonClick) | Function | | Callback to be triggered when the collab button is clicked |
| [`isCollaborating`](#isCollaborating) | `boolean` | | This implies if the app is in collaboration mode |
| [`onPointerUpdate`](#onPointerUpdate) | Function | | Callback triggered when mouse pointer is updated. |
| [`onExportToBackend`](#onExportToBackend) | Function | | Callback triggered when link button is clicked on export dialog |
| [`langCode`](#langCode) | string | `en` | Language code string |
| [`renderTopRightUI`](#renderTopRightUI) | Function | | Function that renders custom UI in top right corner |
| [`renderFooter `](#renderFooter) | Function | | Function that renders custom UI footer |
@ -488,10 +487,6 @@ This callback is triggered when mouse pointer is updated.
3.`pointersMap`: [`pointers map`](https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L131) of the scene
#### `onExportToBackend`
This callback is triggered when the shareable-link button is clicked in the export dialog. The link button will only be shown if this callback is passed.
```js
(exportedElements, appState, canvas) => void
```
@ -571,12 +566,20 @@ This prop can be used to customise UI of Excalidraw. Currently we support custom
| --- | --- | --- | --- |
| `changeViewBackgroundColor` | boolean | true | Implies whether to show `Background color picker` |
| `clearCanvas` | boolean | true | Implies whether to show `Clear canvas button` |
| `export` | boolean | true | Implies whether to show `Export button` |
| `export` | false &#124; [exportOpts](#exportOpts) | <pre>{ saveFileToDisk: true }</pre> | This prop allows to customize the UI inside the export dialog. By default it shows the "saveFileToDisk". If this prop is `false` the export button will not be rendered. For more details visit [`exportOpts`](#exportOpts). |
| `loadScene` | boolean | true | Implies whether to show `Load button` |
| `saveAsScene` | boolean | true | Implies whether to show `Save as button` |
| `saveToActiveFile` | boolean | true | Implies whether to show `Save button` to save to current file |
| `theme` | boolean | true | Implies whether to show `Theme toggle` |
#### `exportOpts`
The below attributes can be set in `UIOptions.canvasActions.export` to customize the export dialog. If `UIOptions.canvasActions.export` is `false` the export button will not be rendered.
| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `saveFileToDisk` | boolean | true | Implies if save file to disk button should be shown |
| `exportToBackend` | <pre> (exportedElements: readonly NonDeletedExcalidrawElement[],appState: AppState,canvas: HTMLCanvasElement &#124; null) => void </pre> | | This callback is triggered when the shareable-link button is clicked in the export dialog. The link button will only be shown if this callback is passed. |
#### `onPaste`
This callback is triggered if passed when something is pasted into the scene. You can use this callback in case you want to do something additional when the paste event occurs.

View File

@ -7,7 +7,7 @@ import App from "../../components/App";
import "../../css/app.scss";
import "../../css/styles.scss";
import { ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { AppProps, ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { defaultLang } from "../../i18n";
import { DEFAULT_UI_OPTIONS } from "../../constants";
@ -19,7 +19,6 @@ const Excalidraw = (props: ExcalidrawProps) => {
onCollabButtonClick,
isCollaborating,
onPointerUpdate,
onExportToBackend,
renderTopRightUI,
renderFooter,
langCode = defaultLang.code,
@ -38,13 +37,19 @@ const Excalidraw = (props: ExcalidrawProps) => {
const canvasActions = props.UIOptions?.canvasActions;
const UIOptions = {
const UIOptions: AppProps["UIOptions"] = {
canvasActions: {
...DEFAULT_UI_OPTIONS.canvasActions,
...canvasActions,
},
};
if (canvasActions?.export) {
UIOptions.canvasActions.export.saveFileToDisk =
canvasActions.export?.saveFileToDisk ||
DEFAULT_UI_OPTIONS.canvasActions.export.saveFileToDisk;
}
useEffect(() => {
// Block pinch-zooming on iOS outside of the content area
const handleTouchMove = (event: TouchEvent) => {
@ -72,7 +77,6 @@ const Excalidraw = (props: ExcalidrawProps) => {
onCollabButtonClick={onCollabButtonClick}
isCollaborating={isCollaborating}
onPointerUpdate={onPointerUpdate}
onExportToBackend={onExportToBackend}
renderTopRightUI={renderTopRightUI}
renderFooter={renderFooter}
langCode={langCode}
@ -99,12 +103,58 @@ const areEqual = (
prevProps: PublicExcalidrawProps,
nextProps: PublicExcalidrawProps,
) => {
const { initialData: prevInitialData, ...prev } = prevProps;
const { initialData: nextInitialData, ...next } = nextProps;
const {
initialData: prevInitialData,
UIOptions: prevUIOptions = {},
...prev
} = prevProps;
const {
initialData: nextInitialData,
UIOptions: nextUIOptions = {},
...next
} = nextProps;
// comparing UIOptions
const prevUIOptionsKeys = Object.keys(prevUIOptions) as (keyof Partial<
typeof DEFAULT_UI_OPTIONS
>)[];
const nextUIOptionsKeys = Object.keys(nextUIOptions) as (keyof Partial<
typeof DEFAULT_UI_OPTIONS
>)[];
if (prevUIOptionsKeys.length !== nextUIOptionsKeys.length) {
return false;
}
const isUIOptionsSame = prevUIOptionsKeys.every((key) => {
if (key === "canvasActions") {
const canvasOptionKeys = Object.keys(
prevUIOptions.canvasActions!,
) as (keyof Partial<typeof DEFAULT_UI_OPTIONS.canvasActions>)[];
canvasOptionKeys.every((key) => {
if (
key === "export" &&
prevUIOptions?.canvasActions?.export &&
nextUIOptions?.canvasActions?.export
) {
return (
prevUIOptions.canvasActions.export.saveFileToDisk ===
nextUIOptions.canvasActions.export.saveFileToDisk
);
}
return (
prevUIOptions?.canvasActions?.[key] ===
nextUIOptions?.canvasActions?.[key]
);
});
}
return true;
});
const prevKeys = Object.keys(prevProps) as (keyof typeof prev)[];
const nextKeys = Object.keys(nextProps) as (keyof typeof next)[];
return (
isUIOptionsSame &&
prevKeys.length === nextKeys.length &&
prevKeys.every((key) => prev[key] === next[key])
);

View File

@ -178,9 +178,11 @@ describe("<Excalidraw/>", () => {
expect(queryByTestId(container, "load-button")).toBeNull();
});
it("should hide save as button when saveAsScene is false", async () => {
it("should hide save as button when saveFileToDisk is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { saveAsScene: false } }} />,
<Excalidraw
UIOptions={{ canvasActions: { export: { saveFileToDisk: false } } }}
/>,
);
expect(queryByTestId(container, "save-as-button")).toBeNull();

View File

@ -178,11 +178,6 @@ export interface ExcalidrawProps {
button: "down" | "up";
pointersMap: Gesture["pointers"];
}) => void;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
onPaste?: (
data: ClipboardData,
event: ClipboardEvent | null,
@ -219,12 +214,20 @@ export enum UserIdleState {
IDLE = "idle",
}
export type ExportOpts = {
saveFileToDisk?: boolean;
onExportToBackend?: (
exportedElements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
canvas: HTMLCanvasElement | null,
) => void;
};
type CanvasActions = {
changeViewBackgroundColor?: boolean;
clearCanvas?: boolean;
export?: boolean;
export?: false | ExportOpts;
loadScene?: boolean;
saveAsScene?: boolean;
saveToActiveFile?: boolean;
theme?: boolean;
};
@ -235,7 +238,7 @@ export type UIOptions = {
export type AppProps = ExcalidrawProps & {
UIOptions: {
canvasActions: Required<CanvasActions>;
canvasActions: Required<CanvasActions> & { export: ExportOpts };
};
detectScroll: boolean;
handleKeyboardGlobally: boolean;