Browse Source

feat: support disabling image tool (#6320)

Co-authored-by: Aakansha Doshi <[email protected]>
David Luzar 1 năm trước cách đây
mục cha
commit
9c425224c7

+ 11 - 1
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<typeof value, keyof AppProps["UIOptions"]["tools"]>
+          ] === false
+        ) {
+          return null;
+        }
+
         const label = t(`toolBar.${value}`);
         const letter =
           key && capitalizeString(typeof key === "string" ? key : key[0]);

+ 47 - 2
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<AppProps, AppState> {
 
       // 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<AppProps, AppState> {
   ) {
     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<AppProps, AppState> {
     }
   });
 
+  // We purposely widen the `tool` type so this helper can be called with
+  // any tool without having to type check it
+  private isToolSupported = <T extends ToolType | "custom">(tool: T) => {
+    return (
+      this.props.UIOptions.tools?.[
+        tool as Extract<T, keyof AppProps["UIOptions"]["tools"]>
+      ] !== false
+    );
+  };
+
   setActiveTool = (
     tool: (
       | (
@@ -3296,6 +3313,13 @@ class App extends React.Component<AppProps, AppState> {
       | { 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<AppProps, AppState> {
     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<AppProps, AppState> {
     );
 
     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<AppProps, AppState> {
           });
       }
     } 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 });
     }
   };

+ 2 - 0
src/components/LayerUI.tsx

@@ -280,6 +280,7 @@ const LayerUI = ({
                           <ShapesSwitcher
                             appState={appState}
                             activeTool={appState.activeTool}
+                            UIOptions={UIOptions}
                             app={app}
                           />
                         </Stack.Row>
@@ -470,6 +471,7 @@ const LayerUI = ({
           renderSidebars={renderSidebars}
           device={device}
           renderWelcomeScreen={renderWelcomeScreen}
+          UIOptions={UIOptions}
         />
       )}
       {!device.editor.isMobile && (

+ 4 - 0
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 = ({
                     <ShapesSwitcher
                       appState={appState}
                       activeTool={appState.activeTool}
+                      UIOptions={UIOptions}
                       app={app}
                     />
                   </Stack.Row>

+ 3 - 0
src/constants.ts

@@ -222,6 +222,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
     toggleTheme: null,
     saveAsImage: true,
   },
+  tools: {
+    image: true,
+  },
 };
 
 // breakpoints

+ 22 - 15
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"));
   }
 };

+ 16 - 0
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;
+  }
+}

+ 1 - 0
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 <bold>Aggressively Block Fingerprinting</bold> setting enabled.",
       "line2": "This could result in breaking the <bold>Text Elements</bold> in your drawings.",

+ 10 - 0
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).

+ 12 - 0
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<Theme>("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
           </label>
+          <label>
+            <input
+              type="checkbox"
+              checked={disableImageTool === true}
+              onChange={() => {
+                setDisableImageTool(!disableImageTool);
+              }}
+            />
+            Disable Image Tool
+          </label>
           <label>
             <input
               type="checkbox"
@@ -686,6 +697,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
               canvasActions: {
                 loadScene: false,
               },
+              tools: { image: !disableImageTool },
             }}
             renderTopRightUI={renderTopRightUI}
             onLinkOpen={onLinkOpen}

+ 3 - 0
src/packages/excalidraw/index.tsx

@@ -56,6 +56,9 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
       ...DEFAULT_UI_OPTIONS.canvasActions,
       ...canvasActions,
     },
+    tools: {
+      image: props.UIOptions?.tools?.image ?? true,
+    },
   };
 
   if (canvasActions?.export) {

+ 5 - 2
src/types.ts

@@ -471,7 +471,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;
@@ -481,9 +481,12 @@ type CanvasActions = Partial<{
   saveAsImage: boolean;
 }>;
 
-type UIOptions = Partial<{
+export type UIOptions = Partial<{
   dockedSidebarBreakpoint: number;
   canvasActions: CanvasActions;
+  tools: {
+    image: boolean;
+  };
   /** @deprecated does nothing. Will be removed in 0.15 */
   welcomeScreen?: boolean;
 }>;