mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: Added Copy/Paste from Google Docs (#7136)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
dde3dac931
commit
63650f82d1
9 changed files with 232 additions and 77 deletions
|
@ -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,21 +2184,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||
{
|
||||
clientX: this.lastViewportPosition.x,
|
||||
|
@ -2206,6 +2192,29 @@ class App extends React.Component<AppProps, AppState> {
|
|||
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 && !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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue