feat: Support customising canvas actions 🎉 (#3364)

* feat: Support hiding save, save as, clear & export

* Remove canvasActions from state & minor changes

* Rename prop to UIOptions & pass default value

* Make requested changes

* better type checking so that optional check not needed at every point

* remove optional checks

* Add few tests

* Add describe block for canvasActions & use snapshot tests

* Add support for hiding canvas background picker

* Take snapshot of canvasActions instead of the whole app

* Add support for hiding dark mode toggle

* Update README.md

* Rename table heading

* Update changelog

* Make requested changes

* Update test name

* tweaks

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
pull/3396/head
Arun 4 years ago committed by GitHub
parent c54a099010
commit 233576628c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -33,6 +33,7 @@ export const actionChangeViewBackgroundColor = register({
type="canvasBackground"
color={appState.viewBackgroundColor}
onChange={(color) => updateData(color)}
data-testid="canvas-background-picker"
/>
</div>
);
@ -72,6 +73,7 @@ export const actionClearCanvas = register({
updateData(null);
}
}}
data-testid="clear-canvas-button"
/>
),
});

@ -136,6 +136,7 @@ export const actionSaveScene = register({
aria-label={t("buttons.save")}
showAriaLabel={useIsMobile()}
onClick={() => updateData(null)}
data-testid="save-button"
/>
),
});
@ -167,6 +168,7 @@ export const actionSaveAsScene = register({
showAriaLabel={useIsMobile()}
hidden={!supported}
onClick={() => updateData(null)}
data-testid="save-as-button"
/>
),
});
@ -204,6 +206,7 @@ export const actionLoadScene = register({
aria-label={t("buttons.load")}
showAriaLabel={useIsMobile()}
onClick={updateData}
data-testid="load-button"
/>
),
});

@ -7,12 +7,12 @@ import {
ActionResult,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types";
import { AppProps, AppState } from "../types";
import { MODES } from "../constants";
// This is the <App> component, but for now we don't care about anything but its
// `canvas` state.
type App = { canvas: HTMLCanvasElement | null; props: ExcalidrawProps };
type App = { canvas: HTMLCanvasElement | null; props: AppProps };
export class ActionManager implements ActionsManagerInterface {
actions = {} as ActionsManagerInterface["actions"];
@ -52,10 +52,14 @@ export class ActionManager implements ActionsManagerInterface {
}
handleKeyDown(event: KeyboardEvent) {
const canvasActions = this.app.props.UIOptions.canvasActions;
const data = Object.values(this.actions)
.sort((a, b) => (b.keyPriority || 0) - (a.keyPriority || 0))
.filter(
(action) =>
(action.name in canvasActions
? canvasActions[action.name as keyof typeof canvasActions]
: true) &&
action.keyTest &&
action.keyTest(
event,
@ -102,7 +106,15 @@ export class ActionManager implements ActionsManagerInterface {
// like the user list. We can use this key to extract more
// data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => {
if (this.actions[name] && "PanelComponent" in this.actions[name]) {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
this.actions[name] &&
"PanelComponent" in this.actions[name] &&
(name in canvasActions
? canvasActions[name as keyof typeof canvasActions]
: true)
) {
const action = this.actions[name];
const PanelComponent = action.PanelComponent!;
const updateData = (formState?: any) => {

@ -44,6 +44,7 @@ import {
import {
APP_NAME,
CURSOR_TYPE,
DEFAULT_UI_OPTIONS,
DEFAULT_VERTICAL_ALIGN,
DRAGGING_THRESHOLD,
ELEMENT_SHIFT_TRANSLATE_AMOUNT,
@ -160,13 +161,7 @@ import Scene from "../scene/Scene";
import { SceneState, ScrollBars } from "../scene/types";
import { getNewZoom } from "../scene/zoom";
import { findShapeByKey } from "../shapes";
import {
AppState,
ExcalidrawProps,
Gesture,
GestureEvent,
SceneData,
} from "../types";
import { AppProps, AppState, Gesture, GestureEvent, SceneData } from "../types";
import {
debounce,
distance,
@ -286,16 +281,21 @@ export type ExcalidrawImperativeAPI = {
ready: true;
};
class App extends React.Component<ExcalidrawProps, AppState> {
class App extends React.Component<AppProps, AppState> {
canvas: HTMLCanvasElement | null = null;
rc: RoughCanvas | null = null;
unmounted: boolean = false;
actionManager: ActionManager;
private excalidrawContainerRef = React.createRef<HTMLDivElement>();
public static defaultProps: Partial<AppProps> = {
// needed for tests to pass since we directly render App in many tests
UIOptions: DEFAULT_UI_OPTIONS,
};
private scene: Scene;
private resizeObserver: ResizeObserver | undefined;
constructor(props: ExcalidrawProps) {
constructor(props: AppProps) {
super(props);
const defaultAppState = getDefaultAppState();
const {
@ -466,8 +466,12 @@ class App extends React.Component<ExcalidrawProps, AppState> {
showExitZenModeBtn={
typeof this.props?.zenModeEnabled === "undefined" && zenModeEnabled
}
showThemeBtn={typeof this.props?.theme === "undefined"}
showThemeBtn={
typeof this.props?.theme === "undefined" &&
this.props.UIOptions.canvasActions.theme
}
libraryReturnUrl={this.props.libraryReturnUrl}
UIOptions={this.props.UIOptions}
/>
<div className="excalidraw-textEditorContainer" />
{this.state.showStats && (
@ -878,7 +882,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
window.addEventListener(EVENT.DROP, this.disableEvent, false);
}
componentDidUpdate(prevProps: ExcalidrawProps, prevState: AppState) {
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
if (prevProps.langCode !== this.props.langCode) {
this.updateLanguage();
}

@ -17,7 +17,13 @@ import { Language, t } from "../i18n";
import { useIsMobile } from "../is-mobile";
import { calculateScrollCenter, getSelectedElements } from "../scene";
import { ExportType } from "../scene/types";
import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types";
import {
AppProps,
AppState,
ExcalidrawProps,
LibraryItem,
LibraryItems,
} from "../types";
import { muteFSAbortError } from "../utils";
import { SelectedShapeActions, ShapesSwitcher, ZoomActions } from "./Actions";
import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle";
@ -65,6 +71,7 @@ interface LayerUIProps {
renderCustomFooter?: (isMobile: boolean) => JSX.Element;
viewModeEnabled: boolean;
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
UIOptions: AppProps["UIOptions"];
}
const useOnClickOutside = (
@ -339,6 +346,7 @@ const LayerUI = ({
renderCustomFooter,
viewModeEnabled,
libraryReturnUrl,
UIOptions,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@ -359,6 +367,10 @@ const LayerUI = ({
);
const renderExportDialog = () => {
if (!UIOptions.canvasActions.export) {
return null;
}
const createExporter = (type: ExportType): ExportCB => async (
exportedElements,
scale,

@ -1,5 +1,6 @@
import { FontFamily } from "./element/types";
import cssVariables from "./css/variables.module.scss";
import { AppProps } from "./types";
export const APP_NAME = "Excalidraw";
@ -124,3 +125,15 @@ export const URL_QUERY_KEYS = {
export const URL_HASH_KEYS = {
addLibrary: "addLibrary",
} as const;
export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
canvasActions: {
changeViewBackgroundColor: true,
clearCanvas: true,
export: true,
loadScene: true,
saveAsScene: true,
saveScene: true,
theme: true,
},
};

@ -18,6 +18,7 @@ Please add the latest change on the top under the correct section.
### Features
- Add `UIOptions` prop to customise `canvas actions` which includes customising `background color picker`, `clear canvas`, `export`, `load`, `save`, `save as` & `theme toggle` [#3364](https://github.com/excalidraw/excalidraw/pull/3364).
- Calculate `width/height` of canvas based on excalidraw component (".excalidraw" selector) & also resize and update offsets whenever the dimensions of excalidraw component gets updated [#3379](https://github.com/excalidraw/excalidraw/pull/3379). You also don't need to add a resize handler anymore for excalidraw as its handled now in excalidraw itself.
#### BREAKING CHANGE
- `width/height` props have been removed. Instead now it takes `100%` of `width` and `height` of the container so you need to make sure the container in which you are rendering Excalidraw has non zero dimensions (It should have non zero width and height so Excalidraw can match the dimensions of containing block)

@ -364,6 +364,7 @@ To view the full example visit :point_down:
| [`libraryReturnUrl`](#libraryReturnUrl) | string | | What URL should [libraries.excalidraw.com](https://libraries.excalidraw.com) be installed to |
| [`theme`](#theme) | `light` or `dark` | | The theme of the Excalidraw component |
| [`name`](#name) | string | | Name of the drawing |
| [`UIOptions`](#UIOptions) | <pre>{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }</pre> | [DEFAULT UI OPTIONS](https://github.com/excalidraw/excalidraw/blob/master/src/constants.ts#L129) | To customise UI options. Currently we support customising [`canvas actions`](#canvasActions) |
### Dimensions of Excalidraw
@ -528,6 +529,26 @@ This prop controls Excalidraw's theme. When supplied, the value takes precedence
This prop sets the name of the drawing which will be used when exporting the drawing. When supplied, the value takes precedence over `intialData.appState.name`, the `name` will be fully controlled by host app and the users won't be able to edit from within Excalidraw.
### `UIOptions`
This prop can be used to customise UI of Excalidraw. Currently we support customising only [`canvasActions`](#canvasActions). It accepts the below parameters
<pre>
{ canvasActions: <a href="https://github.com/excalidraw/excalidraw/blob/master/src/types.ts#L208"> CanvasActions<a/> }
</pre>
#### canvasActions
| Attribute | Type | Default | Description |
| --- | --- | --- | --- |
| `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` |
| `loadScene` | boolean | true | Implies whether to show `Load button` |
| `saveAsScene` | boolean | true | Implies whether to show `Save as button` |
| `saveScene` | boolean | true | Implies whether to show `Save button` |
| `theme` | boolean | true | Implies whether to show `Theme toggle` |
### Does it support collaboration ?
No Excalidraw package doesn't come with collaboration, since this would have different implementations on the consumer so we expose the API's which you can use to communicate with Excalidraw as mentioned above. If you are interested in understanding how Excalidraw does it you can check it [here](https://github.com/excalidraw/excalidraw/blob/master/src/excalidraw-app/index.tsx).

@ -10,6 +10,7 @@ import "../../css/styles.scss";
import { ExcalidrawAPIRefValue, ExcalidrawProps } from "../../types";
import { IsMobileProvider } from "../../is-mobile";
import { defaultLang } from "../../i18n";
import { DEFAULT_UI_OPTIONS } from "../../constants";
const Excalidraw = (props: ExcalidrawProps) => {
const {
@ -31,6 +32,15 @@ const Excalidraw = (props: ExcalidrawProps) => {
renderCustomStats,
} = props;
const canvasActions = props.UIOptions?.canvasActions;
const UIOptions = {
canvasActions: {
...DEFAULT_UI_OPTIONS.canvasActions,
...canvasActions,
},
};
useEffect(() => {
// Block pinch-zooming on iOS outside of the content area
const handleTouchMove = (event: TouchEvent) => {
@ -69,6 +79,7 @@ const Excalidraw = (props: ExcalidrawProps) => {
theme={theme}
name={name}
renderCustomStats={renderCustomStats}
UIOptions={UIOptions}
/>
</IsMobileProvider>
</InitializeApp>
@ -94,6 +105,7 @@ const areEqual = (
Excalidraw.defaultProps = {
lanCode: defaultLang.code,
UIOptions: DEFAULT_UI_OPTIONS,
};
const forwardedRefComp = forwardRef<

@ -0,0 +1,439 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide any UI element when canvasActions is "undefined" 1`] = `
<section
aria-labelledby="canvasActions-title"
class="zen-mode-transition"
>
<h2
class="visually-hidden"
id="canvasActions-title"
>
Canvas actions
</h2>
<div
class="Island"
style="--padding: 2; z-index: 1;"
>
<div
class="Stack Stack_vertical"
style="--gap: 4;"
>
<div
class="Stack Stack_horizontal"
style="--gap: 1; justify-content: space-between;"
>
<button
aria-label="Load"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="load-button"
title="Load"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class="rtl-mirror"
focusable="false"
role="img"
viewBox="0 0 576 512"
>
<path
d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
fill="currentColor"
/>
</svg>
</div>
</button>
<button
aria-label="Save"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="save-button"
title="Save"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 512"
>
<path
d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z"
fill="currentColor"
/>
</svg>
</div>
</button>
<button
aria-label="Save as"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--hide"
data-testid="save-as-button"
hidden=""
title="Save as"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 512"
>
<path
d="M252 54L203 8a28 27 0 00-20-8H28C12 0 0 12 0 27v195c0 15 12 26 28 26h204c15 0 28-11 28-26V73a28 27 0 00-8-19zM130 213c-21 0-37-16-37-36 0-19 16-35 37-35 20 0 37 16 37 35 0 20-17 36-37 36zm56-169v56c0 4-4 6-7 6H44c-4 0-7-2-7-6V42c0-4 3-7 7-7h133l4 2 3 2a7 7 0 012 5z M296 201l87 95-188 205-78 9c-10 1-19-8-18-20l9-84zm141-14l-41-44a31 31 0 00-46 0l-38 41 87 95 38-42c13-14 13-36 0-50z"
fill="currentColor"
/>
</svg>
</div>
</button>
<button
aria-label="Export"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="export-button"
title="Export"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class="rtl-mirror"
focusable="false"
role="img"
viewBox="0 0 576 512"
>
<path
d="M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128zM571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-379 28v-32c0-8.8 7.2-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.8 0-16-7.2-16-16z"
fill="currentColor"
/>
</svg>
</div>
</button>
<button
aria-label="Reset the canvas"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 512"
>
<path
d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
fill="currentColor"
/>
</svg>
</div>
</button>
</div>
<div
style="display: flex;"
>
<div
style="position: relative;"
>
<div>
<div
class="color-picker-control-container"
>
<button
aria-label="Canvas background"
class="color-picker-label-swatch"
style="--swatch-color: #ffffff;"
/>
<label
class="color-input-container"
>
<div
class="color-picker-hash"
>
#
</div>
<input
aria-label="Canvas background"
class="color-picker-input"
spellcheck="false"
value="ffffff"
/>
</label>
</div>
</div>
</div>
<div
style="margin-inline-start: 0.25rem;"
>
<label
class="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
data-testid="toggle-dark-mode"
title="Dark mode"
>
<input
aria-label="Dark mode"
class="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
type="checkbox"
/>
<div
class="ToolIcon__icon"
>
<svg
class="rtl-mirror"
height="512"
viewBox="0 0 512 512"
width="512"
>
<path
d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"
fill="currentColor"
/>
</svg>
</div>
</label>
</div>
</div>
</div>
</div>
</section>
`;
exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when the UIOptions prop is "undefined" 1`] = `
<section
aria-labelledby="canvasActions-title"
class="zen-mode-transition"
>
<h2
class="visually-hidden"
id="canvasActions-title"
>
Canvas actions
</h2>
<div
class="Island"
style="--padding: 2; z-index: 1;"
>
<div
class="Stack Stack_vertical"
style="--gap: 4;"
>
<div
class="Stack Stack_horizontal"
style="--gap: 1; justify-content: space-between;"
>
<button
aria-label="Load"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="load-button"
title="Load"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class="rtl-mirror"
focusable="false"
role="img"
viewBox="0 0 576 512"
>
<path
d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
fill="currentColor"
/>
</svg>
</div>
</button>
<button
aria-label="Save"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="save-button"
title="Save"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 512"
>
<path
d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z"
fill="currentColor"
/>
</svg>
</div>
</button>
<button
aria-label="Save as"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--hide"
data-testid="save-as-button"
hidden=""
title="Save as"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 512"
>
<path
d="M252 54L203 8a28 27 0 00-20-8H28C12 0 0 12 0 27v195c0 15 12 26 28 26h204c15 0 28-11 28-26V73a28 27 0 00-8-19zM130 213c-21 0-37-16-37-36 0-19 16-35 37-35 20 0 37 16 37 35 0 20-17 36-37 36zm56-169v56c0 4-4 6-7 6H44c-4 0-7-2-7-6V42c0-4 3-7 7-7h133l4 2 3 2a7 7 0 012 5z M296 201l87 95-188 205-78 9c-10 1-19-8-18-20l9-84zm141-14l-41-44a31 31 0 00-46 0l-38 41 87 95 38-42c13-14 13-36 0-50z"
fill="currentColor"
/>
</svg>
</div>
</button>
<button
aria-label="Export"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="export-button"
title="Export"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class="rtl-mirror"
focusable="false"
role="img"
viewBox="0 0 576 512"
>
<path
d="M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128zM571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-379 28v-32c0-8.8 7.2-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.8 0-16-7.2-16-16z"
fill="currentColor"
/>
</svg>
</div>
</button>
<button
aria-label="Reset the canvas"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
>
<div
aria-hidden="true"
class="ToolIcon__icon"
>
<svg
aria-hidden="true"
class=""
focusable="false"
role="img"
viewBox="0 0 448 512"
>
<path
d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
fill="currentColor"
/>
</svg>
</div>
</button>
</div>
<div
style="display: flex;"
>
<div
style="position: relative;"
>
<div>
<div
class="color-picker-control-container"
>
<button
aria-label="Canvas background"
class="color-picker-label-swatch"
style="--swatch-color: #ffffff;"
/>
<label
class="color-input-container"
>
<div
class="color-picker-hash"
>
#
</div>
<input
aria-label="Canvas background"
class="color-picker-input"
spellcheck="false"
value="ffffff"
/>
</label>
</div>
</div>
</div>
<div
style="margin-inline-start: 0.25rem;"
>
<label
class="ToolIcon ToolIcon_type_floating ToolIcon_size_M"
data-testid="toggle-dark-mode"
title="Dark mode"
>
<input
aria-label="Dark mode"
class="ToolIcon_type_checkbox ToolIcon_toggle_opaque"
type="checkbox"
/>
<div
class="ToolIcon__icon"
>
<svg
class="rtl-mirror"
height="512"
viewBox="0 0 512 512"
width="512"
>
<path
d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"
fill="currentColor"
/>
</svg>
</div>
</label>
</div>
</div>
</div>
</div>
</section>
`;

@ -130,4 +130,86 @@ describe("<Excalidraw/>", () => {
expect(textInput?.nodeName).toBe("SPAN");
});
});
describe("Test UIOptions prop", () => {
it('should not hide any UI element when the UIOptions prop is "undefined"', async () => {
await render(<Excalidraw />);
const canvasActions = document.querySelector(
'section[aria-labelledby="canvasActions-title"]',
);
expect(canvasActions).toMatchSnapshot();
});
describe("Test canvasActions", () => {
it('should not hide any UI element when canvasActions is "undefined"', async () => {
await render(<Excalidraw UIOptions={{}} />);
const canvasActions = document.querySelector(
'section[aria-labelledby="canvasActions-title"]',
);
expect(canvasActions).toMatchSnapshot();
});
it("should hide clear canvas button when clearCanvas is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { clearCanvas: false } }} />,
);
expect(queryByTestId(container, "clear-canvas-button")).toBeNull();
});
it("should hide export button when export is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { export: false } }} />,
);
expect(queryByTestId(container, "export-button")).toBeNull();
});
it("should hide load button when loadScene is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { loadScene: false } }} />,
);
expect(queryByTestId(container, "load-button")).toBeNull();
});
it("should hide save as button when saveAsScene is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { saveAsScene: false } }} />,
);
expect(queryByTestId(container, "save-as-button")).toBeNull();
});
it("should hide save button when saveScene is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { saveScene: false } }} />,
);
expect(queryByTestId(container, "save-button")).toBeNull();
});
it("should hide the canvas background picker when changeViewBackgroundColor is false", async () => {
const { container } = await render(
<Excalidraw
UIOptions={{ canvasActions: { changeViewBackgroundColor: false } }}
/>,
);
expect(queryByTestId(container, "canvas-background-picker")).toBeNull();
});
it("should hide the theme toggle when theme is false", async () => {
const { container } = await render(
<Excalidraw UIOptions={{ canvasActions: { theme: false } }} />,
);
expect(queryByTestId(container, "toggle-dark-mode")).toBeNull();
});
});
});
});

@ -189,6 +189,7 @@ export interface ExcalidrawProps {
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => JSX.Element;
UIOptions?: UIOptions;
}
export type SceneData = {
@ -203,3 +204,23 @@ export enum UserIdleState {
AWAY = "away",
IDLE = "idle",
}
type CanvasActions = {
changeViewBackgroundColor?: boolean;
clearCanvas?: boolean;
export?: boolean;
loadScene?: boolean;
saveAsScene?: boolean;
saveScene?: boolean;
theme?: boolean;
};
export type UIOptions = {
canvasActions?: CanvasActions;
};
export type AppProps = ExcalidrawProps & {
UIOptions: {
canvasActions: Required<CanvasActions>;
};
};

Loading…
Cancel
Save