|
|
|
@ -47,7 +47,7 @@ import {
|
|
|
|
|
isEraserActive,
|
|
|
|
|
isHandToolActive,
|
|
|
|
|
} from "../appState";
|
|
|
|
|
import { parseClipboard } from "../clipboard";
|
|
|
|
|
import { PastedMixedContent, parseClipboard } from "../clipboard";
|
|
|
|
|
import {
|
|
|
|
|
APP_NAME,
|
|
|
|
|
CURSOR_TYPE,
|
|
|
|
@ -275,6 +275,7 @@ import {
|
|
|
|
|
generateIdFromFile,
|
|
|
|
|
getDataURL,
|
|
|
|
|
getFileFromEvent,
|
|
|
|
|
ImageURLToFile,
|
|
|
|
|
isImageFileHandle,
|
|
|
|
|
isSupportedImageFile,
|
|
|
|
|
loadSceneOrLibraryFromBlob,
|
|
|
|
@ -2183,29 +2184,37 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
|
|
|
|
{
|
|
|
|
|
clientX: this.lastViewportPosition.x,
|
|
|
|
|
clientY: this.lastViewportPosition.y,
|
|
|
|
|
},
|
|
|
|
|
this.state,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// must be called in the same frame (thus before any awaits) as the paste
|
|
|
|
|
// event else some browsers (FF...) will clear the clipboardData
|
|
|
|
|
// (something something security)
|
|
|
|
|
let file = event?.clipboardData?.files[0];
|
|
|
|
|
|
|
|
|
|
const data = await parseClipboard(event, isPlainPaste);
|
|
|
|
|
if (!file && data.text && !isPlainPaste) {
|
|
|
|
|
const string = data.text.trim();
|
|
|
|
|
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
|
|
|
|
// ignore SVG validation/normalization which will be done during image
|
|
|
|
|
// initialization
|
|
|
|
|
file = SVGStringToFile(string);
|
|
|
|
|
if (!file && !isPlainPaste) {
|
|
|
|
|
if (data.mixedContent) {
|
|
|
|
|
return this.addElementsFromMixedContentPaste(data.mixedContent, {
|
|
|
|
|
isPlainPaste,
|
|
|
|
|
sceneX,
|
|
|
|
|
sceneY,
|
|
|
|
|
});
|
|
|
|
|
} else if (data.text) {
|
|
|
|
|
const string = data.text.trim();
|
|
|
|
|
if (string.startsWith("<svg") && string.endsWith("</svg>")) {
|
|
|
|
|
// ignore SVG validation/normalization which will be done during image
|
|
|
|
|
// initialization
|
|
|
|
|
file = SVGStringToFile(string);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
|
|
|
|
{
|
|
|
|
|
clientX: this.lastViewportPosition.x,
|
|
|
|
|
clientY: this.lastViewportPosition.y,
|
|
|
|
|
},
|
|
|
|
|
this.state,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// prefer spreadsheet data over image file (MS Office/Libre Office)
|
|
|
|
|
if (isSupportedImageFile(file) && !data.spreadsheet) {
|
|
|
|
|
const imageElement = this.createImageElement({ sceneX, sceneY });
|
|
|
|
@ -2259,6 +2268,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
});
|
|
|
|
|
} else if (data.text) {
|
|
|
|
|
const maybeUrl = extractSrc(data.text);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!isPlainPaste &&
|
|
|
|
|
embeddableURLValidator(maybeUrl, this.props.validateEmbeddable) &&
|
|
|
|
@ -2393,6 +2403,85 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
this.setActiveTool({ type: "selection" });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// TODO rewrite this to paste both text & images at the same time if
|
|
|
|
|
// pasted data contains both
|
|
|
|
|
private async addElementsFromMixedContentPaste(
|
|
|
|
|
mixedContent: PastedMixedContent,
|
|
|
|
|
{
|
|
|
|
|
isPlainPaste,
|
|
|
|
|
sceneX,
|
|
|
|
|
sceneY,
|
|
|
|
|
}: { isPlainPaste: boolean; sceneX: number; sceneY: number },
|
|
|
|
|
) {
|
|
|
|
|
if (
|
|
|
|
|
!isPlainPaste &&
|
|
|
|
|
mixedContent.some((node) => node.type === "imageUrl")
|
|
|
|
|
) {
|
|
|
|
|
const imageURLs = mixedContent
|
|
|
|
|
.filter((node) => node.type === "imageUrl")
|
|
|
|
|
.map((node) => node.value);
|
|
|
|
|
const responses = await Promise.all(
|
|
|
|
|
imageURLs.map(async (url) => {
|
|
|
|
|
try {
|
|
|
|
|
return { file: await ImageURLToFile(url) };
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
return { errorMessage: error.message as string };
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
let y = sceneY;
|
|
|
|
|
let firstImageYOffsetDone = false;
|
|
|
|
|
const nextSelectedIds: Record<ExcalidrawElement["id"], true> = {};
|
|
|
|
|
for (const response of responses) {
|
|
|
|
|
if (response.file) {
|
|
|
|
|
const imageElement = this.createImageElement({
|
|
|
|
|
sceneX,
|
|
|
|
|
sceneY: y,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const initializedImageElement = await this.insertImageElement(
|
|
|
|
|
imageElement,
|
|
|
|
|
response.file,
|
|
|
|
|
);
|
|
|
|
|
if (initializedImageElement) {
|
|
|
|
|
// vertically center first image in the batch
|
|
|
|
|
if (!firstImageYOffsetDone) {
|
|
|
|
|
firstImageYOffsetDone = true;
|
|
|
|
|
y -= initializedImageElement.height / 2;
|
|
|
|
|
}
|
|
|
|
|
// hack to reset the `y` coord because we vertically center during
|
|
|
|
|
// insertImageElement
|
|
|
|
|
mutateElement(initializedImageElement, { y }, false);
|
|
|
|
|
|
|
|
|
|
y = imageElement.y + imageElement.height + 25;
|
|
|
|
|
|
|
|
|
|
nextSelectedIds[imageElement.id] = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.setState({
|
|
|
|
|
selectedElementIds: makeNextSelectedElementIds(
|
|
|
|
|
nextSelectedIds,
|
|
|
|
|
this.state,
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const error = responses.find((response) => !!response.errorMessage);
|
|
|
|
|
if (error && error.errorMessage) {
|
|
|
|
|
this.setState({ errorMessage: error.errorMessage });
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const textNodes = mixedContent.filter((node) => node.type === "text");
|
|
|
|
|
if (textNodes.length) {
|
|
|
|
|
this.addTextFromPaste(
|
|
|
|
|
textNodes.map((node) => node.value).join("\n\n"),
|
|
|
|
|
isPlainPaste,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private addTextFromPaste(text: string, isPlainPaste = false) {
|
|
|
|
|
const { x, y } = viewportCoordsToSceneCoords(
|
|
|
|
|
{
|
|
|
|
@ -4401,6 +4490,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleCanvasPointerDown = (
|
|
|
|
|
event: React.PointerEvent<HTMLElement>,
|
|
|
|
|
) => {
|
|
|
|
@ -7302,7 +7392,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
this.scene.addNewElement(imageElement);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await this.initializeImage({
|
|
|
|
|
return await this.initializeImage({
|
|
|
|
|
imageFile,
|
|
|
|
|
imageElement,
|
|
|
|
|
showCursorImagePreview,
|
|
|
|
@ -7315,6 +7405,7 @@ class App extends React.Component<AppProps, AppState> {
|
|
|
|
|
this.setState({
|
|
|
|
|
errorMessage: error.message || t("errors.imageInsertError"),
|
|
|
|
|
});
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|