mirror of
https://github.com/outline/outline.git
synced 2026-05-12 13:21:17 -05:00
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:
@@ -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;
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user