feat: Dropping a remote image will now upload (#8086)

* feat: Dropping a remote image will now upload

* refactor,DRY

* guard

* Parse correct file name from url where possible
This commit is contained in:
Tom Moor
2024-12-07 15:46:14 -05:00
committed by GitHub
parent 7fbe442863
commit a738ea97b5
5 changed files with 155 additions and 85 deletions
-78
View File
@@ -1,78 +0,0 @@
import { Plugin } from "prosemirror-state";
import { getDataTransferFiles } from "../../utils/files";
import insertFiles, { Options } from "../commands/insertFiles";
const uploadPlugin = (options: Options) =>
new Plugin({
props: {
handleDOMEvents: {
paste(view, event: ClipboardEvent): boolean {
if (!view.editable || !options.uploadFile) {
return false;
}
if (!event.clipboardData) {
return false;
}
// check if we actually pasted any files
const files = Array.prototype.slice
.call(event.clipboardData.items)
.filter((dt: DataTransferItem) => dt.kind !== "string")
.map((dt: DataTransferItem) => dt.getAsFile())
.filter(Boolean);
if (files.length === 0) {
return false;
}
// When copying from Microsoft Office product the clipboard contains
// an image version of the content, check if there is also text and
// use that instead in this scenario.
const html = event.clipboardData.getData("text/html");
// Fallback to default paste behavior if the clipboard contains HTML
// Even if there is an image, it's likely to be a screenshot from eg
// Microsoft Suite / Apple Numbers and not the original content.
if (html.length && !html.includes("<img")) {
return false;
}
const { tr } = view.state;
if (!tr.selection.empty) {
tr.deleteSelection();
}
const pos = tr.selection.from;
void insertFiles(view, event, pos, files, options);
return true;
},
drop(view, event: DragEvent): boolean {
if (!view.editable || !options.uploadFile) {
return false;
}
// filter to only include image files
const files = getDataTransferFiles(event);
if (files.length === 0) {
return false;
}
// grab the position in the document for the cursor
const result = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (result) {
void insertFiles(view, event, result.pos, files, options);
return true;
}
return false;
},
},
},
});
export default uploadPlugin;
+2 -2
View File
@@ -11,7 +11,7 @@ import insertFiles, { Options } from "../commands/insertFiles";
import { default as ImageComponent } from "../components/Image";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import uploadPlaceholderPlugin from "../lib/uploadPlaceholder";
import uploadPlugin from "../lib/uploadPlugin";
import { UploadPlugin } from "../plugins/UploadPlugin";
import { ComponentProps } from "../types";
import Node from "./Node";
@@ -238,6 +238,6 @@ export default class SimpleImage extends Node {
}
get plugins() {
return [uploadPlaceholderPlugin, uploadPlugin(this.options)];
return [uploadPlaceholderPlugin, new UploadPlugin(this.options)];
}
}
+102
View File
@@ -0,0 +1,102 @@
import { extension } from "mime-types";
import { Plugin } from "prosemirror-state";
import { getDataTransferFiles, getDataTransferImage } from "../../utils/files";
import { fileNameFromUrl, isInternalUrl } from "../../utils/urls";
import insertFiles, { Options } from "../commands/insertFiles";
export class UploadPlugin extends Plugin {
constructor(options: Options) {
super({
props: {
handleDOMEvents: {
paste(view, event: ClipboardEvent): boolean {
if (!view.editable || !options.uploadFile) {
return false;
}
if (!event.clipboardData) {
return false;
}
// check if we actually pasted any files
const files = getDataTransferFiles(event);
if (files.length === 0) {
return false;
}
// When copying from Microsoft Office product the clipboard contains
// an image version of the content, check if there is also text and
// use that instead in this scenario.
const html = event.clipboardData.getData("text/html");
// Fallback to default paste behavior if the clipboard contains HTML
// Even if there is an image, it's likely to be a screenshot from eg
// Microsoft Suite / Apple Numbers and not the original content.
if (html.length && !getDataTransferImage(event)) {
return false;
}
const { tr } = view.state;
if (!tr.selection.empty) {
tr.deleteSelection();
}
const pos = tr.selection.from;
void insertFiles(view, event, pos, files, options);
return true;
},
drop(view, event: DragEvent): boolean {
if (!view.editable || !options.uploadFile) {
return false;
}
// grab the position in the document for the cursor
const result = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!result) {
return false;
}
const files = getDataTransferFiles(event);
if (files.length) {
void insertFiles(view, event, result.pos, files, options);
return true;
}
const imageSrc = getDataTransferImage(event);
if (imageSrc && !isInternalUrl(imageSrc)) {
event.stopPropagation();
event.preventDefault();
void fetch(imageSrc)
.then((response) => response.blob())
.then((blob) => {
const fileName = fileNameFromUrl(imageSrc) ?? "pasted-image";
const ext = extension(blob.type) ?? "png";
const name = fileName.endsWith(`.${ext}`)
? fileName
: `${fileName}.${ext}`;
void insertFiles(
view,
event,
result.pos,
[
new File([blob], name, {
type: blob.type,
}),
],
options
);
});
}
return false;
},
},
},
});
}
}
+30 -5
View File
@@ -25,15 +25,40 @@ export function bytesToHumanReadable(bytes: number | undefined) {
}
/**
* Get an array of File objects from a drag event
* Get an image URL from a drag or clipboard event
*
* @param event The react or native drag event
* @returns An array of Files
* @param event The event to get the image from.
* @returns The URL of the image.
*/
export function getDataTransferImage(
event: React.DragEvent<HTMLElement> | DragEvent | ClipboardEvent
) {
const dt =
event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer;
const untrustedHTML = dt?.getData("text/html");
try {
return untrustedHTML
? new DOMParser()
.parseFromString(untrustedHTML, "text/html")
.querySelector("img")?.src
: dt?.getData("url");
} catch (e) {
return;
}
}
/**
* Get an array of File objects from a drag or clipboard event
*
* @param event The event to get files from.
* @returns An array of files.
*/
export function getDataTransferFiles(
event: React.DragEvent<HTMLElement> | DragEvent
event: React.DragEvent<HTMLElement> | DragEvent | ClipboardEvent
): File[] {
const dt = event.dataTransfer;
const dt =
event instanceof ClipboardEvent ? event.clipboardData : event.dataTransfer;
if (dt) {
if ("files" in dt && dt.files.length) {
+21
View File
@@ -12,6 +12,21 @@ export function cdnPath(path: string): string {
return `${env.CDN_URL ?? ""}${path}`;
}
/**
* Extracts the file name from a given url.
*
* @param url The url to extract the file name from.
* @returns The file name.
*/
export function fileNameFromUrl(url: string) {
try {
const parsed = new URL(url);
return parsed.pathname.split("/").pop();
} catch (err) {
return;
}
}
/**
* Returns true if the given string is a link to inside the application.
*
@@ -146,6 +161,12 @@ export function sanitizeUrl(url: string | null | undefined) {
return url;
}
/**
* Returns a regex to match the given url.
*
* @param url The url to create a regex for.
* @returns A regex to match the url.
*/
export function urlRegex(url: string | null | undefined): RegExp | undefined {
if (!url || !isUrl(url)) {
return undefined;