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:
@@ -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];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user