diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 47b090b8..3a908ebe 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -33,6 +33,7 @@ export const actionChangeViewBackgroundColor = register({ type="canvasBackground" color={appState.viewBackgroundColor} onChange={(color) => updateData(color)} + data-testid="canvas-background-picker" /> ); @@ -72,6 +73,7 @@ export const actionClearCanvas = register({ updateData(null); } }} + data-testid="clear-canvas-button" /> ), }); diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index 04f3a2aa..00220149 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -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" /> ), }); diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index b242508b..3cdc81f0 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -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 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) => { diff --git a/src/components/App.tsx b/src/components/App.tsx index 1f6a5823..8147d340 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 { +class App extends React.Component { canvas: HTMLCanvasElement | null = null; rc: RoughCanvas | null = null; unmounted: boolean = false; actionManager: ActionManager; private excalidrawContainerRef = React.createRef(); + public static defaultProps: Partial = { + // 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 { 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} />
{this.state.showStats && ( @@ -878,7 +882,7 @@ class App extends React.Component { 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(); } diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 77486c36..0ad77681 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -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, diff --git a/src/constants.ts b/src/constants.ts index 4abb3d26..935c7b49 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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, + }, +}; diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 5b7d9652..dbd3437c 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -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) diff --git a/src/packages/excalidraw/README_NEXT.md b/src/packages/excalidraw/README_NEXT.md index c80bee61..6a3ed9ba 100644 --- a/src/packages/excalidraw/README_NEXT.md +++ b/src/packages/excalidraw/README_NEXT.md @@ -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) |
{ canvasActions:  CanvasActions }
| [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 + +
+{ canvasActions:  CanvasActions }
+
+ +#### 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). diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 5e24cef4..2a9f041a 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/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} /> @@ -94,6 +105,7 @@ const areEqual = ( Excalidraw.defaultProps = { lanCode: defaultLang.code, + UIOptions: DEFAULT_UI_OPTIONS, }; const forwardedRefComp = forwardRef< diff --git a/src/tests/__snapshots__/excalidrawPackage.test.tsx.snap b/src/tests/__snapshots__/excalidrawPackage.test.tsx.snap new file mode 100644 index 00000000..e4f48689 --- /dev/null +++ b/src/tests/__snapshots__/excalidrawPackage.test.tsx.snap @@ -0,0 +1,439 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` Test UIOptions prop Test canvasActions should not hide any UI element when canvasActions is "undefined" 1`] = ` +
+

+ Canvas actions +

+
+
+
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+`; + +exports[` Test UIOptions prop should not hide any UI element when the UIOptions prop is "undefined" 1`] = ` +
+

+ Canvas actions +

+
+
+
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+`; diff --git a/src/tests/excalidrawPackage.test.tsx b/src/tests/excalidrawPackage.test.tsx index a64b73f3..f417630f 100644 --- a/src/tests/excalidrawPackage.test.tsx +++ b/src/tests/excalidrawPackage.test.tsx @@ -130,4 +130,86 @@ describe("", () => { 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(); + + 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(); + + 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( + , + ); + + expect(queryByTestId(container, "clear-canvas-button")).toBeNull(); + }); + + it("should hide export button when export is false", async () => { + const { container } = await render( + , + ); + + expect(queryByTestId(container, "export-button")).toBeNull(); + }); + + it("should hide load button when loadScene is false", async () => { + const { container } = await render( + , + ); + + expect(queryByTestId(container, "load-button")).toBeNull(); + }); + + it("should hide save as button when saveAsScene is false", async () => { + const { container } = await render( + , + ); + + expect(queryByTestId(container, "save-as-button")).toBeNull(); + }); + + it("should hide save button when saveScene is false", async () => { + const { container } = await render( + , + ); + + expect(queryByTestId(container, "save-button")).toBeNull(); + }); + + it("should hide the canvas background picker when changeViewBackgroundColor is false", async () => { + const { container } = await render( + , + ); + + expect(queryByTestId(container, "canvas-background-picker")).toBeNull(); + }); + + it("should hide the theme toggle when theme is false", async () => { + const { container } = await render( + , + ); + + expect(queryByTestId(container, "toggle-dark-mode")).toBeNull(); + }); + }); + }); }); diff --git a/src/types.ts b/src/types.ts index 16e9620f..a69732b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; + }; +};