From 4af69b2758a8f6afdab42837c06ca41f9f1f9d37 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 17 Jul 2022 11:31:55 +0100 Subject: [PATCH] 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 --- app/actions/definitions/documents.tsx | 6 +-- app/components/Editor.tsx | 37 ++++++++++++--- app/editor/components/CommandMenu.tsx | 7 ++- app/menus/CollectionMenu.tsx | 6 +-- app/menus/DocumentMenu.tsx | 6 +-- shared/editor/nodes/Image.tsx | 15 +++--- shared/utils/files.ts | 68 ++++++++++++++++++++++++++- shared/utils/getDataTransferFiles.ts | 28 ----------- 8 files changed, 116 insertions(+), 57 deletions(-) delete mode 100644 shared/utils/getDataTransferFiles.ts diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 636a78f2a..344012b1d 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -13,7 +13,7 @@ import { SearchIcon, } from "outline-icons"; import * as React from "react"; -import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; +import { getEventFiles } from "@shared/utils/files"; import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; @@ -260,8 +260,8 @@ export const importDocument = createAction({ input.type = "file"; input.accept = documents.importFileTypes.join(", "); - input.onchange = async (ev: Event) => { - const files = getDataTransferFiles(ev); + input.onchange = async (ev) => { + const files = getEventFiles(ev); try { const file = files[0]; diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index b426203e5..d5d831fa0 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -1,5 +1,6 @@ import { formatDistanceToNow } from "date-fns"; import { deburr, sortBy } from "lodash"; +import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model"; import { TextSelection } from "prosemirror-state"; import * as React from "react"; import mergeRefs from "react-merge-refs"; @@ -7,8 +8,10 @@ import { Optional } from "utility-types"; import insertFiles from "@shared/editor/commands/insertFiles"; import embeds from "@shared/editor/embeds"; import { Heading } from "@shared/editor/lib/getHeadings"; -import { supportedImageMimeTypes } from "@shared/utils/files"; -import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; +import { + getDataTransferFiles, + supportedImageMimeTypes, +} from "@shared/utils/files"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; import { isInternalUrl } from "@shared/utils/urls"; import Document from "~/models/Document"; @@ -177,21 +180,41 @@ function Editor(props: Props, ref: React.RefObject | null) { event.preventDefault(); event.stopPropagation(); const files = getDataTransferFiles(event); + const view = ref?.current?.view; if (!view) { 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. const isAttachment = files.some( (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, { uploadFile: onUploadFile, onFileUploadStart: props.onFileUploadStart, diff --git a/app/editor/components/CommandMenu.tsx b/app/editor/components/CommandMenu.tsx index cef5c981d..f6554fc3d 100644 --- a/app/editor/components/CommandMenu.tsx +++ b/app/editor/components/CommandMenu.tsx @@ -11,8 +11,7 @@ import { CommandFactory } from "@shared/editor/lib/Extension"; import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators"; import { EmbedDescriptor, MenuItem } from "@shared/editor/types"; import { depths } from "@shared/styles"; -import { supportedImageMimeTypes } from "@shared/utils/files"; -import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; +import { supportedImageMimeTypes, getEventFiles } from "@shared/utils/files"; import Scrollable from "~/components/Scrollable"; import { Dictionary } from "~/hooks/useDictionary"; import Input from "./Input"; @@ -275,7 +274,7 @@ class CommandMenu extends React.Component, State> { }; handleFilePicked = (event: React.ChangeEvent) => { - const files = getDataTransferFiles(event); + const files = getEventFiles(event); const { view, @@ -424,7 +423,7 @@ class CommandMenu extends React.Component, State> { const embedItems: EmbedDescriptor[] = []; for (const embed of embeds) { - if (embed.title && embed.icon) { + if (embed.title) { embedItems.push({ ...embed, name: "embed", diff --git a/app/menus/CollectionMenu.tsx b/app/menus/CollectionMenu.tsx index 83521aa01..0fbc5fc9f 100644 --- a/app/menus/CollectionMenu.tsx +++ b/app/menus/CollectionMenu.tsx @@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu"; import { VisuallyHidden } from "reakit/VisuallyHidden"; -import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; +import { getEventFiles } from "@shared/utils/files"; import Collection from "~/models/Collection"; import CollectionEdit from "~/scenes/CollectionEdit"; import CollectionExport from "~/scenes/CollectionExport"; @@ -117,8 +117,8 @@ function CollectionMenu({ ); const handleFilePicked = React.useCallback( - async (ev: React.FormEvent) => { - const files = getDataTransferFiles(ev); + async (ev: React.ChangeEvent) => { + const files = getEventFiles(ev); // 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 diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index a7f5c632a..d26a8780b 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -23,7 +23,7 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; -import getDataTransferFiles from "@shared/utils/getDataTransferFiles"; +import { getEventFiles } from "@shared/utils/files"; import Document from "~/models/Document"; import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; @@ -219,8 +219,8 @@ function DocumentMenu({ ); const handleFilePicked = React.useCallback( - async (ev: React.FormEvent) => { - const files = getDataTransferFiles(ev); + async (ev: React.ChangeEvent) => { + const files = getEventFiles(ev); // 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 diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index 0b37fc127..3c17965b0 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -11,8 +11,11 @@ import { import * as React from "react"; import ImageZoom from "react-medium-image-zoom"; import styled from "styled-components"; -import { supportedImageMimeTypes } from "../../utils/files"; -import getDataTransferFiles from "../../utils/getDataTransferFiles"; +import { + getDataTransferFiles, + supportedImageMimeTypes, + getEventFiles, +} from "../../utils/files"; import insertFiles, { Options } from "../commands/insertFiles"; import { MarkdownSerializerState } from "../lib/markdown/serializer"; import uploadPlaceholderPlugin from "../lib/uploadPlaceholder"; @@ -74,9 +77,7 @@ const uploadPlugin = (options: Options) => } // filter to only include image files - const files = getDataTransferFiles(event).filter( - (dt: any) => dt.kind !== "string" - ); + const files = getDataTransferFiles(event); if (files.length === 0) { return false; } @@ -413,8 +414,8 @@ export default class Image extends Node { const inputElement = document.createElement("input"); inputElement.type = "file"; inputElement.accept = supportedImageMimeTypes.join(", "); - inputElement.onchange = (event: Event) => { - const files = getDataTransferFiles(event); + inputElement.onchange = (event) => { + const files = getEventFiles(event); insertFiles(view, event, state.selection.from, files, { uploadFile, onFileUploadStart, diff --git a/shared/utils/files.ts b/shared/utils/files.ts index f6dbbb72f..cd483e0fc 100644 --- a/shared/utils/files.ts +++ b/shared/utils/files.ts @@ -4,7 +4,7 @@ * @param bytes filesize in bytes * @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( /.{3}/g ); @@ -18,7 +18,71 @@ export const bytesToHumanReadable = (bytes: number) => { return `${Number(out[0])}${f === "00" ? "" : `.${f}`} ${ " kMGTPEZY"[out.length] }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 | 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 | 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 | 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 diff --git a/shared/utils/getDataTransferFiles.ts b/shared/utils/getDataTransferFiles.ts deleted file mode 100644 index 860cb7514..000000000 --- a/shared/utils/getDataTransferFiles.ts +++ /dev/null @@ -1,28 +0,0 @@ -export default function getDataTransferFiles( - event: - | Event - | React.FormEvent - | React.DragEvent -): 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) - : []; -}