From 7bf49bbe6fad655007d4d74bee97aa9d27049acb Mon Sep 17 00:00:00 2001 From: Fernando Moro Date: Tue, 20 Oct 2026 10:30:47 +0200 Subject: [PATCH] fix(core): insert every file when several are dropped or pasted at once handleFileInsertion read each DataTransfer item inside the loop, after awaiting the previous upload. Browsers only expose drag/clipboard data during the synchronous dispatch of the event, so once the first upload resolved the remaining items were gone and only the first file was inserted. Capture the files and their MIME types before the first await. --- .../fromClipboard/handleFileInsertion.test.ts | 92 +++++++++++++++++++ .../fromClipboard/handleFileInsertion.ts | 13 ++- 2 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.test.ts 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,