diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index 2ee9babfc..9306c2c9c 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -14,7 +14,7 @@ import { hasText, } from "../scene"; import { SHAPES } from "../shapes"; -import { AppState, Zoom } from "../types"; +import { AppProps, AppState, CanvasActions, Zoom } from "../types"; import { capitalizeString, isTransparent, @@ -209,15 +209,25 @@ export const ShapesSwitcher = ({ setAppState, onImageAction, appState, + UIOptions, }: { canvas: HTMLCanvasElement | null; activeTool: AppState["activeTool"]; setAppState: React.Component["setState"]; onImageAction: (data: { pointerType: PointerType | null }) => void; appState: AppState; + UIOptions: AppProps["UIOptions"]; }) => ( <> {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { + if ( + UIOptions.canvasActions.tools[ + value as Extract + ] === false + ) { + return null; + } + const label = t(`toolBar.${value}`); const letter = key && capitalizeString(typeof key === "string" ? key : key[0]); diff --git a/src/components/App.tsx b/src/components/App.tsx index a8240f127..e341e0ab8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -204,6 +204,7 @@ import { PointerDownState, SceneData, Device, + CanvasActions, } from "../types"; import { debounce, @@ -285,6 +286,7 @@ import { actionToggleHandTool } from "../actions/actionCanvas"; import { jotaiStore } from "../jotai"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { actionCreateContainerFromText } from "../actions/actionBoundText"; +import { ImageSceneDataError } from "../errors"; const deviceContextInitialValue = { isSmScreen: false, @@ -1532,6 +1534,10 @@ class App extends React.Component { // prefer spreadsheet data over image file (MS Office/Libre Office) if (isSupportedImageFile(file) && !data.spreadsheet) { + if (!this.isToolSupported("image")) { + this.setState({ errorMessage: t("errors.imageToolNotSupported") }); + return; + } const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( { clientX: cursorX, clientY: cursorY }, this.state, @@ -2256,11 +2262,32 @@ class App extends React.Component { } }); + // we pruposefuly widen tyhe `tool` type so this helper can be called with + // any tool without having to type check it + private isToolSupported = < + T extends typeof SHAPES[number]["value"] | "eraser" | "hand" | "custom", + >( + tool: T, + ) => { + return ( + this.props.UIOptions.canvasActions.tools[ + tool as Extract + ] !== false + ); + }; + private setActiveTool = ( tool: | { type: typeof SHAPES[number]["value"] | "eraser" | "hand" } | { type: "custom"; customType: string }, ) => { + if (!this.isToolSupported(tool.type)) { + console.warn( + `"${tool.type}" tool is disabled via "UIOptions.canvasActions.tools.${tool.type}"`, + ); + return; + } + const nextActiveTool = updateActiveTool(this.state, tool); if (nextActiveTool.type === "hand") { setCursor(this.canvas, CURSOR_TYPE.GRAB); @@ -5896,7 +5923,10 @@ class App extends React.Component { const { file, fileHandle } = await getFileFromEvent(event); try { - if (isSupportedImageFile(file)) { + // if image tool not supported, don't show an error here and let it fall + // through so we still support importing scene data from images. If no + // scene data encoded, we'll show an error then + if (isSupportedImageFile(file) && this.isToolSupported("image")) { // first attempt to decode scene from the image if it's embedded // --------------------------------------------------------------------- @@ -6005,6 +6035,17 @@ class App extends React.Component { }); } } catch (error: any) { + if ( + error instanceof ImageSceneDataError && + error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && + !this.isToolSupported("image") + ) { + this.setState({ + isLoading: false, + errorMessage: t("errors.imageToolNotSupported"), + }); + return; + } this.setState({ isLoading: false, errorMessage: error.message }); } }; diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index bea074d62..57bfe32f6 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -305,6 +305,7 @@ const LayerUI = ({ insertOnCanvasDirectly: pointerType !== "mouse", }); }} + UIOptions={UIOptions} /> @@ -409,6 +410,7 @@ const LayerUI = ({ renderSidebars={renderSidebars} device={device} renderWelcomeScreen={renderWelcomeScreen} + UIOptions={UIOptions} /> )} diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 75e08867c..3b70e050e 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { AppState, Device, ExcalidrawProps } from "../types"; +import { AppProps, AppState, Device, ExcalidrawProps } from "../types"; import { ActionManager } from "../actions/manager"; import { t } from "../i18n"; import Stack from "./Stack"; @@ -42,6 +42,7 @@ type MobileMenuProps = { renderSidebars: () => JSX.Element | null; device: Device; renderWelcomeScreen: boolean; + UIOptions: AppProps["UIOptions"]; }; export const MobileMenu = ({ @@ -59,6 +60,7 @@ export const MobileMenu = ({ renderSidebars, device, renderWelcomeScreen, + UIOptions, }: MobileMenuProps) => { const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels(); const renderToolbar = () => { @@ -82,6 +84,7 @@ export const MobileMenu = ({ insertOnCanvasDirectly: pointerType !== "mouse", }); }} + UIOptions={UIOptions} /> diff --git a/src/constants.ts b/src/constants.ts index aa25667a2..26a17d60b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -161,6 +161,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { saveToActiveFile: true, toggleTheme: null, saveAsImage: true, + tools: { + image: true, + }, }, }; diff --git a/src/data/blob.ts b/src/data/blob.ts index 473042b56..3ecb2889a 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -3,7 +3,7 @@ import { cleanAppStateForExport } from "../appState"; import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { clearElementsForExport } from "../element"; import { ExcalidrawElement, FileId } from "../element/types"; -import { CanvasError } from "../errors"; +import { CanvasError, ImageSceneDataError } from "../errors"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { AppState, DataURL, LibraryItem } from "../types"; @@ -23,15 +23,12 @@ const parseFileContents = async (blob: Blob | File) => { ).decodePngMetadata(blob); } catch (error: any) { if (error.message === "INVALID") { - throw new DOMException( + throw new ImageSceneDataError( t("alerts.imageDoesNotContainScene"), - "EncodingError", + "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { - throw new DOMException( - t("alerts.cannotRestoreFromImage"), - "EncodingError", - ); + throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage")); } } } else { @@ -57,15 +54,12 @@ const parseFileContents = async (blob: Blob | File) => { }); } catch (error: any) { if (error.message === "INVALID") { - throw new DOMException( + throw new ImageSceneDataError( t("alerts.imageDoesNotContainScene"), - "EncodingError", + "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { - throw new DOMException( - t("alerts.cannotRestoreFromImage"), - "EncodingError", - ); + throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage")); } } } diff --git a/src/errors.ts b/src/errors.ts index e0444d105..4df403496 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -16,3 +16,19 @@ export class AbortError extends DOMException { super(message, "AbortError"); } } + +type ImageSceneDataErrorCode = + | "IMAGE_NOT_CONTAINS_SCENE_DATA" + | "IMAGE_SCENE_DATA_ERROR"; + +export class ImageSceneDataError extends Error { + public code; + constructor( + message = "Image Scene Data Error", + code: ImageSceneDataErrorCode = "IMAGE_SCENE_DATA_ERROR", + ) { + super(message); + this.name = "EncodingError"; + this.code = code; + } +} diff --git a/src/locales/en.json b/src/locales/en.json index f5ae003f3..19bab5709 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -206,7 +206,8 @@ "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.", "importLibraryError": "Couldn't load library", "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.", - "collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work." + "collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.", + "imageToolNotSupported": "Images are disabled." }, "toolBar": { "selection": "Selection", diff --git a/src/packages/excalidraw/index.tsx b/src/packages/excalidraw/index.tsx index 4f768c6d5..83c947316 100644 --- a/src/packages/excalidraw/index.tsx +++ b/src/packages/excalidraw/index.tsx @@ -52,6 +52,10 @@ const ExcalidrawBase = (props: ExcalidrawProps) => { canvasActions: { ...DEFAULT_UI_OPTIONS.canvasActions, ...canvasActions, + tools: { + ...DEFAULT_UI_OPTIONS.canvasActions.tools, + ...props.UIOptions?.canvasActions?.tools, + }, }, }; diff --git a/src/types.ts b/src/types.ts index 486ec7146..073a251d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -373,7 +373,7 @@ export type ExportOpts = { // truthiness value will determine whether the action is rendered or not // (see manager renderAction). We also override canvasAction values in // excalidraw package index.tsx. -type CanvasActions = Partial<{ +export type CanvasActions = Partial<{ changeViewBackgroundColor: boolean; clearCanvas: boolean; export: false | ExportOpts; @@ -381,6 +381,9 @@ type CanvasActions = Partial<{ saveToActiveFile: boolean; toggleTheme: boolean | null; saveAsImage: boolean; + tools: { + image: boolean; + }; }>; type UIOptions = Partial<{