|
|
|
@@ -161,7 +161,6 @@ import {
|
|
|
|
|
maybeParseEmbedSrc,
|
|
|
|
|
getEmbedLink,
|
|
|
|
|
getInitializedImageElements,
|
|
|
|
|
loadHTMLImageElement,
|
|
|
|
|
normalizeSVG,
|
|
|
|
|
updateImageCache as _updateImageCache,
|
|
|
|
|
getBoundTextElement,
|
|
|
|
@@ -258,7 +257,6 @@ import type {
|
|
|
|
|
ExcalidrawEmbeddableElement,
|
|
|
|
|
Ordered,
|
|
|
|
|
MagicGenerationData,
|
|
|
|
|
ExcalidrawNonSelectionElement,
|
|
|
|
|
ExcalidrawArrowElement,
|
|
|
|
|
ExcalidrawElbowArrowElement,
|
|
|
|
|
} from "@excalidraw/element/types";
|
|
|
|
@@ -338,7 +336,6 @@ import {
|
|
|
|
|
} from "../scene";
|
|
|
|
|
import { getStateForZoom } from "../scene/zoom";
|
|
|
|
|
import {
|
|
|
|
|
dataURLToFile,
|
|
|
|
|
dataURLToString,
|
|
|
|
|
generateIdFromFile,
|
|
|
|
|
getDataURL,
|
|
|
|
@@ -440,7 +437,6 @@ import type {
|
|
|
|
|
AppProps,
|
|
|
|
|
AppState,
|
|
|
|
|
BinaryFileData,
|
|
|
|
|
DataURL,
|
|
|
|
|
ExcalidrawImperativeAPI,
|
|
|
|
|
BinaryFiles,
|
|
|
|
|
Gesture,
|
|
|
|
@@ -1520,7 +1516,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
width: this.state.width,
|
|
|
|
|
editingTextElement: this.state.editingTextElement,
|
|
|
|
|
newElementId: this.state.newElement?.id,
|
|
|
|
|
pendingImageElementId: this.state.pendingImageElementId,
|
|
|
|
|
});
|
|
|
|
|
this.visibleElements = visibleElements;
|
|
|
|
|
|
|
|
|
@@ -4711,16 +4706,10 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setActiveTool = (
|
|
|
|
|
tool: (
|
|
|
|
|
| (
|
|
|
|
|
| { type: Exclude<ToolType, "image"> }
|
|
|
|
|
| {
|
|
|
|
|
type: Extract<ToolType, "image">;
|
|
|
|
|
insertOnCanvasDirectly?: boolean;
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
| { type: "custom"; customType: string }
|
|
|
|
|
) & { locked?: boolean; fromSelection?: boolean },
|
|
|
|
|
tool: ({ type: ToolType } | { type: "custom"; customType: string }) & {
|
|
|
|
|
locked?: boolean;
|
|
|
|
|
fromSelection?: boolean;
|
|
|
|
|
},
|
|
|
|
|
keepSelection = false,
|
|
|
|
|
) => {
|
|
|
|
|
if (!this.isToolSupported(tool.type)) {
|
|
|
|
@@ -4746,10 +4735,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
this.setState({ suggestedBindings: [] });
|
|
|
|
|
}
|
|
|
|
|
if (nextActiveTool.type === "image") {
|
|
|
|
|
this.onImageAction({
|
|
|
|
|
insertOnCanvasDirectly:
|
|
|
|
|
(tool.type === "image" && tool.insertOnCanvasDirectly) ?? false,
|
|
|
|
|
});
|
|
|
|
|
this.onImageAction();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setState((prevState) => {
|
|
|
|
@@ -6595,34 +6581,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
this.state.activeTool.type,
|
|
|
|
|
pointerDownState,
|
|
|
|
|
);
|
|
|
|
|
} else if (this.state.activeTool.type === "image") {
|
|
|
|
|
// reset image preview on pointerdown
|
|
|
|
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.CROSSHAIR);
|
|
|
|
|
|
|
|
|
|
// retrieve the latest element as the state may be stale
|
|
|
|
|
const pendingImageElement =
|
|
|
|
|
this.state.pendingImageElementId &&
|
|
|
|
|
this.scene.getElement(this.state.pendingImageElementId);
|
|
|
|
|
|
|
|
|
|
if (!pendingImageElement) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
|
newElement: pendingImageElement as ExcalidrawNonSelectionElement,
|
|
|
|
|
pendingImageElementId: null,
|
|
|
|
|
multiElement: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { x, y } = viewportCoordsToSceneCoords(event, this.state);
|
|
|
|
|
|
|
|
|
|
const frame = this.getTopLayerFrameAtSceneCoords({ x, y });
|
|
|
|
|
|
|
|
|
|
this.scene.mutateElement(pendingImageElement, {
|
|
|
|
|
x,
|
|
|
|
|
y,
|
|
|
|
|
frameId: frame ? frame.id : null,
|
|
|
|
|
});
|
|
|
|
|
} else if (this.state.activeTool.type === "freedraw") {
|
|
|
|
|
this.handleFreeDrawElementOnPointerDown(
|
|
|
|
|
event,
|
|
|
|
@@ -6646,7 +6604,8 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
);
|
|
|
|
|
} else if (
|
|
|
|
|
this.state.activeTool.type !== "eraser" &&
|
|
|
|
|
this.state.activeTool.type !== "hand"
|
|
|
|
|
this.state.activeTool.type !== "hand" &&
|
|
|
|
|
this.state.activeTool.type !== "image"
|
|
|
|
|
) {
|
|
|
|
|
this.createGenericElementOnPointerDown(
|
|
|
|
|
this.state.activeTool.type,
|
|
|
|
@@ -9092,10 +9051,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
pointerDownState.eventListeners.onKeyUp!,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (this.state.pendingImageElementId) {
|
|
|
|
|
this.setState({ pendingImageElementId: null });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.props?.onPointerUp?.(activeTool, pointerDownState);
|
|
|
|
|
this.onPointerUpEmitter.trigger(
|
|
|
|
|
this.state.activeTool,
|
|
|
|
@@ -9873,11 +9828,9 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
private initializeImage = async ({
|
|
|
|
|
imageFile,
|
|
|
|
|
imageElement: _imageElement,
|
|
|
|
|
showCursorImagePreview = false,
|
|
|
|
|
}: {
|
|
|
|
|
imageFile: File;
|
|
|
|
|
imageElement: ExcalidrawImageElement;
|
|
|
|
|
showCursorImagePreview?: boolean;
|
|
|
|
|
}) => {
|
|
|
|
|
// at this point this should be guaranteed image file, but we do this check
|
|
|
|
|
// to satisfy TS down the line
|
|
|
|
@@ -9935,16 +9888,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (showCursorImagePreview) {
|
|
|
|
|
const dataURL = this.files[fileId]?.dataURL;
|
|
|
|
|
// optimization so that we don't unnecessarily resize the original
|
|
|
|
|
// full-size file for cursor preview
|
|
|
|
|
// (it's much faster to convert the resized dataURL to File)
|
|
|
|
|
const resizedFile = dataURL && dataURLToFile(dataURL);
|
|
|
|
|
|
|
|
|
|
this.setImagePreviewCursor(resizedFile || imageFile);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dataURL =
|
|
|
|
|
this.files[fileId]?.dataURL || (await getDataURL(imageFile));
|
|
|
|
|
|
|
|
|
@@ -9983,11 +9926,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
|
|
|
|
|
const imageHTML = await cachedImageData?.image;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
imageHTML &&
|
|
|
|
|
this.state.pendingImageElementId !== imageElement.id &&
|
|
|
|
|
this.state.newElement?.id !== imageElement.id
|
|
|
|
|
) {
|
|
|
|
|
if (imageHTML && this.state.newElement?.id !== imageElement.id) {
|
|
|
|
|
const naturalDimensions = this.getImageNaturalDimensions(
|
|
|
|
|
imageElement,
|
|
|
|
|
imageHTML,
|
|
|
|
@@ -10000,10 +9939,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error(error);
|
|
|
|
|
reject(new Error(t("errors.imageInsertError")));
|
|
|
|
|
} finally {
|
|
|
|
|
if (!showCursorImagePreview) {
|
|
|
|
|
resetCursor(this.interactiveCanvas);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
@@ -10015,7 +9950,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
insertImageElement = async (
|
|
|
|
|
imageElement: ExcalidrawImageElement,
|
|
|
|
|
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
|
|
|
|
@@ -10030,7 +9964,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
const image = await this.initializeImage({
|
|
|
|
|
imageFile,
|
|
|
|
|
imageElement,
|
|
|
|
|
showCursorImagePreview,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const nextElements = this.scene
|
|
|
|
@@ -10063,58 +9996,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private setImagePreviewCursor = async (imageFile: File) => {
|
|
|
|
|
// mustn't be larger than 128 px
|
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property
|
|
|
|
|
const cursorImageSizePx = 96;
|
|
|
|
|
let imagePreview;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
imagePreview = await resizeImageFile(imageFile, {
|
|
|
|
|
maxWidthOrHeight: cursorImageSizePx,
|
|
|
|
|
});
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
if (e.cause === "UNSUPPORTED") {
|
|
|
|
|
throw new Error(t("errors.unsupportedFileType"));
|
|
|
|
|
}
|
|
|
|
|
throw e;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let previewDataURL = await getDataURL(imagePreview);
|
|
|
|
|
|
|
|
|
|
// SVG cannot be resized via `resizeImageFile` so we resize by rendering to
|
|
|
|
|
// a small canvas
|
|
|
|
|
if (imageFile.type === MIME_TYPES.svg) {
|
|
|
|
|
const img = await loadHTMLImageElement(previewDataURL);
|
|
|
|
|
|
|
|
|
|
let height = Math.min(img.height, cursorImageSizePx);
|
|
|
|
|
let width = height * (img.width / img.height);
|
|
|
|
|
|
|
|
|
|
if (width > cursorImageSizePx) {
|
|
|
|
|
width = cursorImageSizePx;
|
|
|
|
|
height = width * (img.height / img.width);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
|
|
canvas.height = height;
|
|
|
|
|
canvas.width = width;
|
|
|
|
|
const context = canvas.getContext("2d")!;
|
|
|
|
|
|
|
|
|
|
context.drawImage(img, 0, 0, width, height);
|
|
|
|
|
|
|
|
|
|
previewDataURL = canvas.toDataURL(MIME_TYPES.svg) as DataURL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.state.pendingImageElementId) {
|
|
|
|
|
setCursor(this.interactiveCanvas, `url(${previewDataURL}) 4 4, auto`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private onImageAction = async ({
|
|
|
|
|
insertOnCanvasDirectly,
|
|
|
|
|
}: {
|
|
|
|
|
insertOnCanvasDirectly: boolean;
|
|
|
|
|
}) => {
|
|
|
|
|
private onImageAction = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const clientX = this.state.width / 2 + this.state.offsetLeft;
|
|
|
|
|
const clientY = this.state.height / 2 + this.state.offsetTop;
|
|
|
|
@@ -10137,35 +10019,20 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
addToFrameUnderCursor: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (insertOnCanvasDirectly) {
|
|
|
|
|
this.insertImageElement(imageElement, imageFile);
|
|
|
|
|
this.initializeImageDimensions(imageElement);
|
|
|
|
|
this.store.scheduleCapture();
|
|
|
|
|
this.setState(
|
|
|
|
|
{
|
|
|
|
|
selectedElementIds: makeNextSelectedElementIds(
|
|
|
|
|
{ [imageElement.id]: true },
|
|
|
|
|
this.state,
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
this.actionManager.executeAction(actionFinalize);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
this.setState(
|
|
|
|
|
{
|
|
|
|
|
pendingImageElementId: imageElement.id,
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
this.insertImageElement(
|
|
|
|
|
imageElement,
|
|
|
|
|
imageFile,
|
|
|
|
|
/* showCursorImagePreview */ true,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
this.insertImageElement(imageElement, imageFile);
|
|
|
|
|
this.initializeImageDimensions(imageElement);
|
|
|
|
|
this.store.scheduleCapture();
|
|
|
|
|
this.setState(
|
|
|
|
|
{
|
|
|
|
|
selectedElementIds: makeNextSelectedElementIds(
|
|
|
|
|
{ [imageElement.id]: true },
|
|
|
|
|
this.state,
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
() => {
|
|
|
|
|
this.actionManager.executeAction(actionFinalize);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
if (error.name !== "AbortError") {
|
|
|
|
|
console.error(error);
|
|
|
|
@@ -10174,7 +10041,6 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
}
|
|
|
|
|
this.setState(
|
|
|
|
|
{
|
|
|
|
|
pendingImageElementId: null,
|
|
|
|
|
newElement: null,
|
|
|
|
|
activeTool: updateActiveTool(this.state, { type: "selection" }),
|
|
|
|
|
},
|
|
|
|
|