feat: support disabling image tool

This commit is contained in:
dwelle 2023-03-05 22:59:01 +01:00
parent cef6094d4c
commit df9af0052d
10 changed files with 95 additions and 18 deletions

View File

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

View File

@ -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<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 { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
{ clientX: cursorX, clientY: cursorY },
this.state,
@ -2256,11 +2262,32 @@ class App extends React.Component<AppProps, AppState> {
}
});
// 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<T, keyof CanvasActions["tools"]>
] !== 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<AppProps, AppState> {
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<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 });
}
};

View File

@ -305,6 +305,7 @@ const LayerUI = ({
insertOnCanvasDirectly: pointerType !== "mouse",
});
}}
UIOptions={UIOptions}
/>
</Stack.Row>
</Island>
@ -409,6 +410,7 @@ const LayerUI = ({
renderSidebars={renderSidebars}
device={device}
renderWelcomeScreen={renderWelcomeScreen}
UIOptions={UIOptions}
/>
)}

View File

@ -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}
/>
</Stack.Row>
</Island>

View File

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

View File

@ -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"));
}
}
}

View File

@ -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;
}
}

View File

@ -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",

View File

@ -52,6 +52,10 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
canvasActions: {
...DEFAULT_UI_OPTIONS.canvasActions,
...canvasActions,
tools: {
...DEFAULT_UI_OPTIONS.canvasActions.tools,
...props.UIOptions?.canvasActions?.tools,
},
},
};

View File

@ -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<{