fix: Moving an image to empty space results in endless upload (#3799)

* fix: Error dragging images below doc, types

* fix: Handle html/text content dropped into padding

* refactor, docs
This commit is contained in:
Tom Moor
2022-07-17 11:31:55 +01:00
committed by GitHub
parent dee87f15af
commit 4af69b2758
8 changed files with 116 additions and 57 deletions

View File

@@ -13,7 +13,7 @@ import {
SearchIcon, SearchIcon,
} from "outline-icons"; } from "outline-icons";
import * as React from "react"; import * as React from "react";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; import { getEventFiles } from "@shared/utils/files";
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
import { createAction } from "~/actions"; import { createAction } from "~/actions";
import { DocumentSection } from "~/actions/sections"; import { DocumentSection } from "~/actions/sections";
@@ -260,8 +260,8 @@ export const importDocument = createAction({
input.type = "file"; input.type = "file";
input.accept = documents.importFileTypes.join(", "); input.accept = documents.importFileTypes.join(", ");
input.onchange = async (ev: Event) => { input.onchange = async (ev) => {
const files = getDataTransferFiles(ev); const files = getEventFiles(ev);
try { try {
const file = files[0]; const file = files[0];

View File

@@ -1,5 +1,6 @@
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { deburr, sortBy } from "lodash"; import { deburr, sortBy } from "lodash";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
import { TextSelection } from "prosemirror-state"; import { TextSelection } from "prosemirror-state";
import * as React from "react"; import * as React from "react";
import mergeRefs from "react-merge-refs"; import mergeRefs from "react-merge-refs";
@@ -7,8 +8,10 @@ import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles"; import insertFiles from "@shared/editor/commands/insertFiles";
import embeds from "@shared/editor/embeds"; import embeds from "@shared/editor/embeds";
import { Heading } from "@shared/editor/lib/getHeadings"; import { Heading } from "@shared/editor/lib/getHeadings";
import { supportedImageMimeTypes } from "@shared/utils/files"; import {
import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; getDataTransferFiles,
supportedImageMimeTypes,
} from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls"; import { isInternalUrl } from "@shared/utils/urls";
import Document from "~/models/Document"; import Document from "~/models/Document";
@@ -177,21 +180,41 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const files = getDataTransferFiles(event); const files = getDataTransferFiles(event);
const view = ref?.current?.view; const view = ref?.current?.view;
if (!view) { if (!view) {
return; return;
} }
// Find a valid position at the end of the document to insert our content
const pos = TextSelection.near(
view.state.doc.resolve(view.state.doc.nodeSize - 2)
).from;
// If there are no files in the drop event attempt to parse the html
// as a fragment and insert it at the end of the document
if (files.length === 0) {
const text =
event.dataTransfer.getData("text/html") ||
event.dataTransfer.getData("text/plain");
const dom = new DOMParser().parseFromString(text, "text/html");
view.dispatch(
view.state.tr.insert(
pos,
ProsemirrorDOMParser.fromSchema(view.state.schema).parse(dom)
)
);
return;
}
// Insert all files as attachments if any of the files are not images. // Insert all files as attachments if any of the files are not images.
const isAttachment = files.some( const isAttachment = files.some(
(file) => !supportedImageMimeTypes.includes(file.type) (file) => !supportedImageMimeTypes.includes(file.type)
); );
// Find a valid position at the end of the document
const pos = TextSelection.near(
view.state.doc.resolve(view.state.doc.nodeSize - 2)
).from;
insertFiles(view, event, pos, files, { insertFiles(view, event, pos, files, {
uploadFile: onUploadFile, uploadFile: onUploadFile,
onFileUploadStart: props.onFileUploadStart, onFileUploadStart: props.onFileUploadStart,

View File

@@ -11,8 +11,7 @@ import { CommandFactory } from "@shared/editor/lib/Extension";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import { EmbedDescriptor, MenuItem } from "@shared/editor/types"; import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
import { depths } from "@shared/styles"; import { depths } from "@shared/styles";
import { supportedImageMimeTypes } from "@shared/utils/files"; import { supportedImageMimeTypes, getEventFiles } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
import Scrollable from "~/components/Scrollable"; import Scrollable from "~/components/Scrollable";
import { Dictionary } from "~/hooks/useDictionary"; import { Dictionary } from "~/hooks/useDictionary";
import Input from "./Input"; import Input from "./Input";
@@ -275,7 +274,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
}; };
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => { handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(event); const files = getEventFiles(event);
const { const {
view, view,
@@ -424,7 +423,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const embedItems: EmbedDescriptor[] = []; const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) { for (const embed of embeds) {
if (embed.title && embed.icon) { if (embed.title) {
embedItems.push({ embedItems.push({
...embed, ...embed,
name: "embed", name: "embed",

View File

@@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu"; import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden"; import { VisuallyHidden } from "reakit/VisuallyHidden";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; import { getEventFiles } from "@shared/utils/files";
import Collection from "~/models/Collection"; import Collection from "~/models/Collection";
import CollectionEdit from "~/scenes/CollectionEdit"; import CollectionEdit from "~/scenes/CollectionEdit";
import CollectionExport from "~/scenes/CollectionExport"; import CollectionExport from "~/scenes/CollectionExport";
@@ -117,8 +117,8 @@ function CollectionMenu({
); );
const handleFilePicked = React.useCallback( const handleFilePicked = React.useCallback(
async (ev: React.FormEvent<HTMLInputElement>) => { async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(ev); const files = getEventFiles(ev);
// Because this is the onChange handler it's possible for the change to be // Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty // from previously selecting a file to not selecting a file aka empty

View File

@@ -23,7 +23,7 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
import { VisuallyHidden } from "reakit/VisuallyHidden"; import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components"; import styled from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; import { getEventFiles } from "@shared/utils/files";
import Document from "~/models/Document"; import Document from "~/models/Document";
import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentDelete from "~/scenes/DocumentDelete";
import DocumentMove from "~/scenes/DocumentMove"; import DocumentMove from "~/scenes/DocumentMove";
@@ -219,8 +219,8 @@ function DocumentMenu({
); );
const handleFilePicked = React.useCallback( const handleFilePicked = React.useCallback(
async (ev: React.FormEvent<HTMLInputElement>) => { async (ev: React.ChangeEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(ev); const files = getEventFiles(ev);
// Because this is the onChange handler it's possible for the change to be // Because this is the onChange handler it's possible for the change to be
// from previously selecting a file to not selecting a file aka empty // from previously selecting a file to not selecting a file aka empty

View File

@@ -11,8 +11,11 @@ import {
import * as React from "react"; import * as React from "react";
import ImageZoom from "react-medium-image-zoom"; import ImageZoom from "react-medium-image-zoom";
import styled from "styled-components"; import styled from "styled-components";
import { supportedImageMimeTypes } from "../../utils/files"; import {
import getDataTransferFiles from "../../utils/getDataTransferFiles"; getDataTransferFiles,
supportedImageMimeTypes,
getEventFiles,
} from "../../utils/files";
import insertFiles, { Options } from "../commands/insertFiles"; import insertFiles, { Options } from "../commands/insertFiles";
import { MarkdownSerializerState } from "../lib/markdown/serializer"; import { MarkdownSerializerState } from "../lib/markdown/serializer";
import uploadPlaceholderPlugin from "../lib/uploadPlaceholder"; import uploadPlaceholderPlugin from "../lib/uploadPlaceholder";
@@ -74,9 +77,7 @@ const uploadPlugin = (options: Options) =>
} }
// filter to only include image files // filter to only include image files
const files = getDataTransferFiles(event).filter( const files = getDataTransferFiles(event);
(dt: any) => dt.kind !== "string"
);
if (files.length === 0) { if (files.length === 0) {
return false; return false;
} }
@@ -413,8 +414,8 @@ export default class Image extends Node {
const inputElement = document.createElement("input"); const inputElement = document.createElement("input");
inputElement.type = "file"; inputElement.type = "file";
inputElement.accept = supportedImageMimeTypes.join(", "); inputElement.accept = supportedImageMimeTypes.join(", ");
inputElement.onchange = (event: Event) => { inputElement.onchange = (event) => {
const files = getDataTransferFiles(event); const files = getEventFiles(event);
insertFiles(view, event, state.selection.from, files, { insertFiles(view, event, state.selection.from, files, {
uploadFile, uploadFile,
onFileUploadStart, onFileUploadStart,

View File

@@ -4,7 +4,7 @@
* @param bytes filesize in bytes * @param bytes filesize in bytes
* @returns Human readable filesize as a string * @returns Human readable filesize as a string
*/ */
export const bytesToHumanReadable = (bytes: number) => { export function bytesToHumanReadable(bytes: number) {
const out = ("0".repeat((bytes.toString().length * 2) % 3) + bytes).match( const out = ("0".repeat((bytes.toString().length * 2) % 3) + bytes).match(
/.{3}/g /.{3}/g
); );
@@ -18,7 +18,71 @@ export const bytesToHumanReadable = (bytes: number) => {
return `${Number(out[0])}${f === "00" ? "" : `.${f}`} ${ return `${Number(out[0])}${f === "00" ? "" : `.${f}`} ${
" kMGTPEZY"[out.length] " kMGTPEZY"[out.length]
}B`; }B`;
}; }
/**
* Get an array of File objects from a drag event
*
* @param event The react or native drag event
* @returns An array of Files
*/
export function getDataTransferFiles(
event: React.DragEvent<HTMLElement> | DragEvent
): File[] {
const dt = event.dataTransfer;
if (dt) {
if ("files" in dt && dt.files.length) {
return dt.files ? Array.prototype.slice.call(dt.files) : [];
}
if ("items" in dt && dt.items.length) {
return dt.items
? Array.prototype.slice
.call(dt.items)
.filter((dt: DataTransferItem) => dt.kind !== "string")
.map((dt: DataTransferItem) => dt.getAsFile())
.filter(Boolean)
: [];
}
}
return [];
}
/**
* Get an array of DataTransferItems from a drag event
*
* @param event The react or native drag event
* @returns An array of DataTransferItems
*/
export function getDataTransferItems(
event: React.DragEvent<HTMLElement> | DragEvent
): DataTransferItem[] {
const dt = event.dataTransfer;
if (dt) {
if ("items" in dt && dt.items.length) {
return dt.items ? Array.prototype.slice.call(dt.items) : [];
}
}
return [];
}
/**
* Get an array of Files from an input event
*
* @param event The react or native input event
* @returns An array of Files
*/
export function getEventFiles(
event: React.ChangeEvent<HTMLInputElement> | Event
): File[] {
return event.target && "files" in event.target
? Array.prototype.slice.call(event.target.files)
: [];
}
/** /**
* An array of image mimetypes commonly supported by modern browsers * An array of image mimetypes commonly supported by modern browsers

View File

@@ -1,28 +0,0 @@
export default function getDataTransferFiles(
event:
| Event
| React.FormEvent<HTMLInputElement>
| React.DragEvent<HTMLElement>
): File[] {
let dataTransferItemsList!: FileList | DataTransferItemList;
if ("dataTransfer" in event) {
const dt = event.dataTransfer;
if (dt.files && dt.files.length) {
dataTransferItemsList = dt.files;
} else if (dt.items && dt.items.length) {
// During the drag even the dataTransfer.files is null
// but Chrome implements some drag store, which is accesible via dataTransfer.items
dataTransferItemsList = dt.items;
}
} else if (event.target && "files" in event.target) {
// @ts-expect-error fallback
dataTransferItemsList = event.target.files;
}
// Convert from DataTransferItemsList to the native Array
return dataTransferItemsList
? Array.prototype.slice.call(dataTransferItemsList)
: [];
}