feat: support disabling image tool (#6320)

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
This commit is contained in:
David Luzar 2023-11-14 10:25:41 +01:00 committed by GitHub
parent 9d1d45a8ea
commit 9c425224c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 136 additions and 20 deletions

View File

@ -13,7 +13,7 @@ import {
hasStrokeWidth, hasStrokeWidth,
} from "../scene"; } from "../scene";
import { SHAPES } from "../shapes"; import { SHAPES } from "../shapes";
import { AppClassProperties, UIAppState, Zoom } from "../types"; import { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
import { capitalizeString, isTransparent } from "../utils"; import { capitalizeString, isTransparent } from "../utils";
import Stack from "./Stack"; import Stack from "./Stack";
import { ToolButton } from "./ToolButton"; import { ToolButton } from "./ToolButton";
@ -218,10 +218,12 @@ export const ShapesSwitcher = ({
activeTool, activeTool,
appState, appState,
app, app,
UIOptions,
}: { }: {
activeTool: UIAppState["activeTool"]; activeTool: UIAppState["activeTool"];
appState: UIAppState; appState: UIAppState;
app: AppClassProperties; app: AppClassProperties;
UIOptions: AppProps["UIOptions"];
}) => { }) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
@ -232,6 +234,14 @@ export const ShapesSwitcher = ({
return ( return (
<> <>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { {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 label = t(`toolBar.${value}`);
const letter = const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]); key && capitalizeString(typeof key === "string" ? key : key[0]);

View File

@ -341,6 +341,7 @@ import {
import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas"; import { actionToggleHandTool, zoomToFit } from "../actions/actionCanvas";
import { jotaiStore } from "../jotai"; import { jotaiStore } from "../jotai";
import { activeConfirmDialogAtom } from "./ActiveConfirmDialog"; import { activeConfirmDialogAtom } from "./ActiveConfirmDialog";
import { ImageSceneDataError } from "../errors";
import { import {
getSnapLinesAtPointer, getSnapLinesAtPointer,
snapDraggedElements, snapDraggedElements,
@ -2272,6 +2273,11 @@ class App extends React.Component<AppProps, AppState> {
// prefer spreadsheet data over image file (MS Office/Libre Office) // prefer spreadsheet data over image file (MS Office/Libre Office)
if (isSupportedImageFile(file) && !data.spreadsheet) { if (isSupportedImageFile(file) && !data.spreadsheet) {
if (!this.isToolSupported("image")) {
this.setState({ errorMessage: t("errors.imageToolNotSupported") });
return;
}
const imageElement = this.createImageElement({ sceneX, sceneY }); const imageElement = this.createImageElement({ sceneX, sceneY });
this.insertImageElement(imageElement, file); this.insertImageElement(imageElement, file);
this.initializeImageDimensions(imageElement); this.initializeImageDimensions(imageElement);
@ -2477,7 +2483,8 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
if ( if (
!isPlainPaste && !isPlainPaste &&
mixedContent.some((node) => node.type === "imageUrl") mixedContent.some((node) => node.type === "imageUrl") &&
this.isToolSupported("image")
) { ) {
const imageURLs = mixedContent const imageURLs = mixedContent
.filter((node) => node.type === "imageUrl") .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 = ( setActiveTool = (
tool: ( tool: (
| ( | (
@ -3296,6 +3313,13 @@ class App extends React.Component<AppProps, AppState> {
| { type: "custom"; customType: string } | { type: "custom"; customType: string }
) & { locked?: boolean }, ) & { 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); const nextActiveTool = updateActiveTool(this.state, tool);
if (nextActiveTool.type === "hand") { if (nextActiveTool.type === "hand") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
@ -7479,6 +7503,13 @@ class App extends React.Component<AppProps, AppState> {
imageFile: File, imageFile: File,
showCursorImagePreview?: boolean, 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); this.scene.addNewElement(imageElement);
try { try {
@ -7863,7 +7894,10 @@ class App extends React.Component<AppProps, AppState> {
); );
try { 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 // 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) { } 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 }); this.setState({ isLoading: false, errorMessage: error.message });
} }
}; };

View File

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

View File

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { import {
AppClassProperties, AppClassProperties,
AppProps,
AppState, AppState,
Device, Device,
ExcalidrawProps, ExcalidrawProps,
@ -45,6 +46,7 @@ type MobileMenuProps = {
renderSidebars: () => JSX.Element | null; renderSidebars: () => JSX.Element | null;
device: Device; device: Device;
renderWelcomeScreen: boolean; renderWelcomeScreen: boolean;
UIOptions: AppProps["UIOptions"];
app: AppClassProperties; app: AppClassProperties;
}; };
@ -62,6 +64,7 @@ export const MobileMenu = ({
renderSidebars, renderSidebars,
device, device,
renderWelcomeScreen, renderWelcomeScreen,
UIOptions,
app, app,
}: MobileMenuProps) => { }: MobileMenuProps) => {
const { const {
@ -83,6 +86,7 @@ export const MobileMenu = ({
<ShapesSwitcher <ShapesSwitcher
appState={appState} appState={appState}
activeTool={appState.activeTool} activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app} app={app}
/> />
</Stack.Row> </Stack.Row>

View File

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

View File

@ -3,7 +3,7 @@ import { cleanAppStateForExport } from "../appState";
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
import { clearElementsForExport } from "../element"; import { clearElementsForExport } from "../element";
import { ExcalidrawElement, FileId } from "../element/types"; import { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError } from "../errors"; import { CanvasError, ImageSceneDataError } from "../errors";
import { t } from "../i18n"; import { t } from "../i18n";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { AppState, DataURL, LibraryItem } from "../types"; import { AppState, DataURL, LibraryItem } from "../types";
@ -24,15 +24,12 @@ const parseFileContents = async (blob: Blob | File) => {
).decodePngMetadata(blob); ).decodePngMetadata(blob);
} catch (error: any) { } catch (error: any) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"), t("alerts.imageDoesNotContainScene"),
"EncodingError", "IMAGE_NOT_CONTAINS_SCENE_DATA",
); );
} else { } else {
throw new DOMException( throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
} }
} }
} else { } else {
@ -58,15 +55,12 @@ const parseFileContents = async (blob: Blob | File) => {
}); });
} catch (error: any) { } catch (error: any) {
if (error.message === "INVALID") { if (error.message === "INVALID") {
throw new DOMException( throw new ImageSceneDataError(
t("alerts.imageDoesNotContainScene"), t("alerts.imageDoesNotContainScene"),
"EncodingError", "IMAGE_NOT_CONTAINS_SCENE_DATA",
); );
} else { } else {
throw new DOMException( throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage"));
t("alerts.cannotRestoreFromImage"),
"EncodingError",
);
} }
} }
} }
@ -131,8 +125,19 @@ export const loadSceneOrLibraryFromBlob = async (
fileHandle?: FileSystemHandle | null, fileHandle?: FileSystemHandle | null,
) => { ) => {
const contents = await parseFileContents(blob); const contents = await parseFileContents(blob);
let data;
try { 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)) { if (isValidExcalidrawData(data)) {
return { return {
type: MIME_TYPES.excalidraw, type: MIME_TYPES.excalidraw,
@ -162,7 +167,9 @@ export const loadSceneOrLibraryFromBlob = async (
} }
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} catch (error: any) { } catch (error: any) {
console.error(error.message); if (error instanceof ImageSceneDataError) {
throw error;
}
throw new Error(t("alerts.couldNotLoadInvalidFile")); throw new Error(t("alerts.couldNotLoadInvalidFile"));
} }
}; };

View File

@ -16,3 +16,19 @@ export class AbortError extends DOMException {
super(message, "AbortError"); 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;
}
}

View File

@ -209,6 +209,7 @@
"importLibraryError": "Couldn't load library", "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": "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.",
"brave_measure_text_error": { "brave_measure_text_error": {
"line1": "Looks like you are using Brave browser with the <bold>Aggressively Block Fingerprinting</bold> setting enabled.", "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.", "line2": "This could result in breaking the <bold>Text Elements</bold> in your drawings.",

View File

@ -15,6 +15,16 @@ Please add the latest change on the top under the correct section.
### Features ### 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). - 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). - Export [`getCommonBounds`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/utils#getcommonbounds) helper from the package [#7247](https://github.com/excalidraw/excalidraw/pull/7247).

View File

@ -98,6 +98,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
const [exportWithDarkMode, setExportWithDarkMode] = useState(false); const [exportWithDarkMode, setExportWithDarkMode] = useState(false);
const [exportEmbedScene, setExportEmbedScene] = useState(false); const [exportEmbedScene, setExportEmbedScene] = useState(false);
const [theme, setTheme] = useState<Theme>("light"); const [theme, setTheme] = useState<Theme>("light");
const [disableImageTool, setDisableImageTool] = useState(false);
const [isCollaborating, setIsCollaborating] = useState(false); const [isCollaborating, setIsCollaborating] = useState(false);
const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>( const [commentIcons, setCommentIcons] = useState<{ [id: string]: Comment }>(
{}, {},
@ -606,6 +607,16 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
/> />
Switch to Dark Theme Switch to Dark Theme
</label> </label>
<label>
<input
type="checkbox"
checked={disableImageTool === true}
onChange={() => {
setDisableImageTool(!disableImageTool);
}}
/>
Disable Image Tool
</label>
<label> <label>
<input <input
type="checkbox" type="checkbox"
@ -686,6 +697,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
canvasActions: { canvasActions: {
loadScene: false, loadScene: false,
}, },
tools: { image: !disableImageTool },
}} }}
renderTopRightUI={renderTopRightUI} renderTopRightUI={renderTopRightUI}
onLinkOpen={onLinkOpen} onLinkOpen={onLinkOpen}

View File

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

View File

@ -471,7 +471,7 @@ export type ExportOpts = {
// truthiness value will determine whether the action is rendered or not // truthiness value will determine whether the action is rendered or not
// (see manager renderAction). We also override canvasAction values in // (see manager renderAction). We also override canvasAction values in
// excalidraw package index.tsx. // excalidraw package index.tsx.
type CanvasActions = Partial<{ export type CanvasActions = Partial<{
changeViewBackgroundColor: boolean; changeViewBackgroundColor: boolean;
clearCanvas: boolean; clearCanvas: boolean;
export: false | ExportOpts; export: false | ExportOpts;
@ -481,9 +481,12 @@ type CanvasActions = Partial<{
saveAsImage: boolean; saveAsImage: boolean;
}>; }>;
type UIOptions = Partial<{ export type UIOptions = Partial<{
dockedSidebarBreakpoint: number; dockedSidebarBreakpoint: number;
canvasActions: CanvasActions; canvasActions: CanvasActions;
tools: {
image: boolean;
};
/** @deprecated does nothing. Will be removed in 0.15 */ /** @deprecated does nothing. Will be removed in 0.15 */
welcomeScreen?: boolean; welcomeScreen?: boolean;
}>; }>;