From 9c425224c789d083bf16e0597ce4a429b9ee008e Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 14 Nov 2023 10:25:41 +0100 Subject: [PATCH] feat: support disabling image tool (#6320) Co-authored-by: Aakansha Doshi --- src/components/Actions.tsx | 12 +++++- src/components/App.tsx | 49 ++++++++++++++++++++++++- src/components/LayerUI.tsx | 2 + src/components/MobileMenu.tsx | 4 ++ src/constants.ts | 3 ++ src/data/blob.ts | 37 +++++++++++-------- src/errors.ts | 16 ++++++++ src/locales/en.json | 1 + src/packages/excalidraw/CHANGELOG.md | 10 +++++ src/packages/excalidraw/example/App.tsx | 12 ++++++ src/packages/excalidraw/index.tsx | 3 ++ src/types.ts | 7 +++- 12 files changed, 136 insertions(+), 20 deletions(-) diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index bd8cb8b0..6d1d80b1 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -13,7 +13,7 @@ import { hasStrokeWidth, } from "../scene"; import { SHAPES } from "../shapes"; -import { AppClassProperties, UIAppState, Zoom } from "../types"; +import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types"; import { capitalizeString, isTransparent } from "../utils"; import Stack from "./Stack"; import { ToolButton } from "./ToolButton"; @@ -218,10 +218,12 @@ export const ShapesSwitcher = ({ activeTool, appState, app, + UIOptions, }: { activeTool: UIAppState["activeTool"]; appState: UIAppState; app: AppClassProperties; + UIOptions: AppProps["UIOptions"]; }) => { const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); @@ -232,6 +234,14 @@ export const ShapesSwitcher = ({ return ( <> {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { + if ( + UIOptions.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 18615e9c..42080e83 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -341,6 +341,7 @@ import { import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas"; import { jotaiStore } from "../jotai"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; +import { ImageSceneDataError } from "../errors"; import { getSnapLinesAtPointer, snapDraggedElements, @@ -2272,6 +2273,11 @@ 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 imageElement = this.createImageElement({ sceneX, sceneY }); this.insertImageElement(imageElement, file); this.initializeImageDimensions(imageElement); @@ -2477,7 +2483,8 @@ class App extends React.Component { ) { if ( !isPlainPaste && - mixedContent.some((node) => node.type === "imageUrl") + mixedContent.some((node) => node.type === "imageUrl") && + this.isToolSupported("image") ) { const imageURLs = mixedContent .filter((node) => node.type === "imageUrl") @@ -3284,6 +3291,16 @@ class App extends React.Component { } }); + // We purposely widen the `tool` type so this helper can be called with + // any tool without having to type check it + private isToolSupported = (tool: T) => { + return ( + this.props.UIOptions.tools?.[ + tool as Extract + ] !== false + ); + }; + setActiveTool = ( tool: ( | ( @@ -3296,6 +3313,13 @@ class App extends React.Component { | { type: "custom"; customType: string } ) & { locked?: boolean }, ) => { + 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.interactiveCanvas, CURSOR_TYPE.GRAB); @@ -7479,6 +7503,13 @@ class App extends React.Component { imageFile: File, showCursorImagePreview?: boolean, ) => { + // we should be handling all cases upstream, but in case we forget to handle + // a future case, let's throw here + if (!this.isToolSupported("image")) { + this.setState({ errorMessage: t("errors.imageToolNotSupported") }); + return; + } + this.scene.addNewElement(imageElement); try { @@ -7863,7 +7894,10 @@ class App extends React.Component { ); 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 // --------------------------------------------------------------------- @@ -7991,6 +8025,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 5c092fea..b32adcf3 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -280,6 +280,7 @@ const LayerUI = ({ @@ -470,6 +471,7 @@ const LayerUI = ({ renderSidebars={renderSidebars} device={device} renderWelcomeScreen={renderWelcomeScreen} + UIOptions={UIOptions} /> )} {!device.editor.isMobile && ( diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 4299bf84..91d0c518 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -1,6 +1,7 @@ import React from "react"; import { AppClassProperties, + AppProps, AppState, Device, ExcalidrawProps, @@ -45,6 +46,7 @@ type MobileMenuProps = { renderSidebars: () => JSX.Element | null; device: Device; renderWelcomeScreen: boolean; + UIOptions: AppProps["UIOptions"]; app: AppClassProperties; }; @@ -62,6 +64,7 @@ export const MobileMenu = ({ renderSidebars, device, renderWelcomeScreen, + UIOptions, app, }: MobileMenuProps) => { const { @@ -83,6 +86,7 @@ export const MobileMenu = ({ diff --git a/src/constants.ts b/src/constants.ts index fca1c0d2..247349f6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -222,6 +222,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { toggleTheme: null, saveAsImage: true, }, + tools: { + image: true, + }, }; // breakpoints diff --git a/src/data/blob.ts b/src/data/blob.ts index 81ce340f..b1b62570 100644 --- a/src/data/blob.ts +++ b/src/data/blob.ts @@ -3,7 +3,7 @@ import { cleanAppStateForExport } from "../appState"; import { 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"; @@ -24,15 +24,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 { @@ -58,15 +55,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")); } } } @@ -131,8 +125,19 @@ export const loadSceneOrLibraryFromBlob = async ( fileHandle?: FileSystemHandle | null, ) => { const contents = await parseFileContents(blob); + let data; try { - const data = JSON.parse(contents); + try { + data = JSON.parse(contents); + } catch (error: any) { + if (isSupportedImageFile(blob)) { + throw new ImageSceneDataError( + t("alerts.imageDoesNotContainScene"), + "IMAGE_NOT_CONTAINS_SCENE_DATA", + ); + } + throw error; + } if (isValidExcalidrawData(data)) { return { type: MIME_TYPES.excalidraw, @@ -162,7 +167,9 @@ export const loadSceneOrLibraryFromBlob = async ( } throw new Error(t("alerts.couldNotLoadInvalidFile")); } catch (error: any) { - console.error(error.message); + if (error instanceof ImageSceneDataError) { + throw error; + } throw new Error(t("alerts.couldNotLoadInvalidFile")); } }; diff --git a/src/errors.ts b/src/errors.ts index e0444d10..4df40349 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 846d2dbc..3b4eba6d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -209,6 +209,7 @@ "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.", + "imageToolNotSupported": "Images are disabled.", "brave_measure_text_error": { "line1": "Looks like you are using Brave browser with the Aggressively Block Fingerprinting setting enabled.", "line2": "This could result in breaking the Text Elements in your drawings.", diff --git a/src/packages/excalidraw/CHANGELOG.md b/src/packages/excalidraw/CHANGELOG.md index 0cb36135..00c10775 100644 --- a/src/packages/excalidraw/CHANGELOG.md +++ b/src/packages/excalidraw/CHANGELOG.md @@ -15,6 +15,16 @@ Please add the latest change on the top under the correct section. ### Features +- Added support for disabling `image` tool (also disabling image insertion in general, though keeps support for importing from `.excalidraw` files) [#6320](https://github.com/excalidraw/excalidraw/pull/6320). + +For disabling `image` you need to set 👇 + +``` +UIOptions.tools = { + image: false +} +``` + - Support `excalidrawAPI` prop for accessing the Excalidraw API [#7251](https://github.com/excalidraw/excalidraw/pull/7251). - Export [`getCommonBounds`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#getcommonbounds) helper from the package [#7247](https://github.com/excalidraw/excalidraw/pull/7247). diff --git a/src/packages/excalidraw/example/App.tsx b/src/packages/excalidraw/example/App.tsx index 974bbb7e..0f905678 100644 --- a/src/packages/excalidraw/example/App.tsx +++ b/src/packages/excalidraw/example/App.tsx @@ -98,6 +98,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { const [exportWithDarkMode, setExportWithDarkMode] = useState(false); const [exportEmbedScene, setExportEmbedScene] = useState(false); const [theme, setTheme] = useState("light"); + const [disableImageTool, setDisableImageTool] = useState(false); const [isCollaborating, setIsCollaborating] = useState(false); const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>( {}, @@ -606,6 +607,16 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) { /> Switch to Dark Theme +