diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.test.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.test.ts index 1ef63daebe..c15bae66cb 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.test.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.test.ts @@ -5,6 +5,128 @@ import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { handleFileInsertion } from "./handleFileInsertion.js"; describe("File insertion", () => { + describe.each(["success", "rejection", "empty URL"] as const)( + "%s for the first upload", + (outcome) => { + it.each(["before", "after", "paste"] as const)( + "preserves transfer order (%s a nonempty paragraph)", + async (placement) => { + let finishFirstUpload!: () => void; + let finishSecondUpload!: () => void; + const firstPending = new Promise((resolve) => { + finishFirstUpload = resolve; + }); + const secondPending = new Promise((resolve) => { + finishSecondUpload = resolve; + }); + const uploadFile = vi.fn(async (file: File) => { + if (file.name === "image.gif") { + await firstPending; + if (outcome === "rejection") { + throw new Error("Upload rejected"); + } + if (outcome === "empty URL") { + return ""; + } + } else { + await secondPending; + } + return `https://example.com/${file.name}`; + }); + const editor = BlockNoteEditor.create({ + initialContent: [ + { id: "target", type: "paragraph", content: "Wrapped paragraph" }, + ], + trailingBlock: false, + uploadFile, + }); + + try { + editor.setTextCursorPosition("target", "end"); + // Only layout is substituted: resolve the real target node after each + // insertion, as posAtCoords would for a drop over the paragraph. + Object.defineProperty(editor, "prosemirrorView", { + value: { + posAtCoords: () => { + let targetPos = 0; + editor.prosemirrorState.doc.descendants((node, pos) => { + if (node.attrs.id === "target") { + targetPos = pos + 1; + } + }); + return { pos: targetPos, inside: -1 }; + }, + } satisfies Pick< + BlockNoteEditor["prosemirrorView"], + "posAtCoords" + >, + }); + Object.defineProperty(editor, "domElement", { + value: { + querySelector: () => ({ + getBoundingClientRect: () => ({ top: 0, bottom: 100 }), + }), + }, + }); + const files = [ + new File(["first"], "image.gif", { type: "image/gif" }), + new File(["second"], "picture.png", { type: "image/png" }), + ]; + const event = + placement === "paste" + ? new ClipboardEvent("paste", { cancelable: true }) + : new DragEvent("drop", { cancelable: true }); + Object.defineProperties(event, { + [placement === "paste" ? "clipboardData" : "dataTransfer"]: { + value: { + types: ["Files"], + items: files.map((file) => ({ + type: file.type, + getAsFile: () => file, + })), + }, + }, + clientX: { value: 10 }, + clientY: { value: placement === "before" ? 10 : 90 }, + }); + + const insertion = handleFileInsertion(event, editor); + // Uploads stay sequential. Let the first finish while the second is + // still pending, then check that its completion cannot reorder blocks. + expect(uploadFile).toHaveBeenCalledTimes(1); + finishFirstUpload(); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(2)); + + expect(event.defaultPrevented).toBe(true); + expect(uploadFile.mock.calls.map(([file]) => file)).toEqual(files); + const order = editor.document.map((block) => + "name" in block.props ? block.props.name : block.id, + ); + expect + .soft(order) + .toEqual( + placement === "before" + ? ["image.gif", "picture.png", "target"] + : ["target", "image.gif", "picture.png"], + ); + finishSecondUpload(); + await insertion; + expect( + editor.document + .filter((block) => block.type === "image") + .map((block) => block.props.url), + ).toEqual([ + outcome === "success" ? "https://example.com/image.gif" : "", + "https://example.com/picture.png", + ]); + } finally { + editor._tiptapEditor.destroy(); + } + }, + ); + }, + ); + it.each(["drop", "paste"] as const)( "uploads and inserts all files in order after %s data becomes unavailable", async (eventType) => { diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index 31ac5cd104..7d911663c0 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -111,6 +111,8 @@ export async function handleFileInsertion< type: item.type, })); + let previousBlockId: string | undefined; + for (const { file, type } of files) { // Gets file block corresponding to MIME type. let fileBlockType = "file"; @@ -146,7 +148,18 @@ export async function handleFileInsertion< let insertedBlockId: string | undefined = undefined; - if (event.type === "paste") { + const previousBlock = previousBlockId + ? editor.getBlock(previousBlockId) + : undefined; + if (previousBlock) { + // Resolve the event's insertion point only for the first file. Reusing + // it for every file reverses insertions after a nonempty block. + insertedBlockId = editor.insertBlocks( + [fileBlock], + previousBlock, + "after", + )[0].id; + } else if (event.type === "paste") { const currentBlock = editor.getTextCursorPosition().block; insertedBlockId = insertOrUpdateBlock(editor, currentBlock, fileBlock); } else if (event.type === "drop") { @@ -195,6 +208,8 @@ export async function handleFileInsertion< return; } + previousBlockId = insertedBlockId; + const updateData = await editor .uploadFile(file, insertedBlockId) .catch(() => undefined);