diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.test.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.test.ts new file mode 100644 index 0000000000..1ef63daebe --- /dev/null +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { handleFileInsertion } from "./handleFileInsertion.js"; + +describe("File insertion", () => { + it.each(["drop", "paste"] as const)( + "uploads and inserts all files in order after %s data becomes unavailable", + async (eventType) => { + let finishUpload!: () => void; + const uploadPending = new Promise((resolve) => { + finishUpload = resolve; + }); + const uploadFile = vi.fn(async (file: File) => { + await uploadPending; + return `https://example.com/${file.name}`; + }); + const editor = BlockNoteEditor.create({ + initialContent: [{ type: "paragraph" }], + trailingBlock: false, + uploadFile, + }); + + try { + // Resolve drops to the first block without mounting a view. + Object.defineProperty(editor, "prosemirrorView", { + value: { + posAtCoords: () => ({ pos: 1, inside: -1 }), + } satisfies Pick, + }); + const files = [ + new File(["first"], "first.png", { type: "image/png" }), + new File(["second"], "second.pdf", { type: "application/pdf" }), + ]; + let protectedData = false; + const items = files.map((file) => ({ + kind: "file", + type: file.type, + getAsFile: () => (protectedData ? null : file), + })); + const event = + eventType === "drop" + ? new DragEvent("drop", { cancelable: true }) + : new ClipboardEvent("paste", { cancelable: true }); + Object.defineProperty( + event, + eventType === "drop" ? "dataTransfer" : "clipboardData", + { value: { types: ["Files"], items } }, + ); + const target = new EventTarget(); + let insertion = Promise.resolve(); + target.addEventListener(eventType, () => { + insertion = handleFileInsertion(event, editor); + }); + target.dispatchEvent(event); + // Native event data is only available during synchronous dispatch. + protectedData = true; + items.length = 0; + expect(uploadFile).toHaveBeenCalledTimes(1); + finishUpload(); + await insertion; + + expect.soft(uploadFile).toHaveBeenCalledTimes(2); + expect( + editor.document.map((block) => ({ + type: block.type, + props: block.props, + })), + ).toMatchObject([ + { + type: "image", + props: { + name: "first.png", + url: "https://example.com/first.png", + }, + }, + { + type: "file", + props: { + name: "second.pdf", + url: "https://example.com/second.pdf", + }, + }, + ]); + expect(uploadFile.mock.calls.map(([file]) => file)).toEqual(files); + } finally { + editor._tiptapEditor.destroy(); + } + }, + ); +}); diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index 9561927f0f..fcce065021 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -105,20 +105,24 @@ export async function handleFileInsertion< event.preventDefault(); - for (let i = 0; i < items.length; i++) { + // Capture files before awaiting uploads, while event data is still available. + const files = Array.from(items, (item) => ({ + file: item.getAsFile(), + type: item.type, + })); + + for (const { file, type } of files) { // Gets file block corresponding to MIME type. let fileBlockType = "file"; for (const blockSpec of Object.values(editor.schema.blockSpecs)) { for (const mimeType of blockSpec.implementation.meta.fileBlockAccept || []) { const isFileExtension = mimeType.startsWith("."); - const file = items[i].getAsFile(); - if (file) { if ( (!isFileExtension && file.type && - checkMIMETypesMatch(items[i].type, mimeType)) || + checkMIMETypesMatch(type, mimeType)) || (isFileExtension && checkFileExtensionsMatch( "." + file.name.split(".").pop(), @@ -132,7 +136,6 @@ export async function handleFileInsertion< } } - const file = items[i].getAsFile(); if (file) { const fileBlock = { type: fileBlockType,