diff --git a/frontend/src/react/components/OpBlockNoteEditor.tsx b/frontend/src/react/components/OpBlockNoteEditor.tsx
index 55e1f385173b..12efd08a77b4 100644
--- a/frontend/src/react/components/OpBlockNoteEditor.tsx
+++ b/frontend/src/react/components/OpBlockNoteEditor.tsx
@@ -29,6 +29,7 @@ import { BlockNoteEditorOptions, BlockNoteSchema, User } from '@blocknote/core';
import { ExternalLinkA11yExtension } from '../extensions/external-link-a11y';
import { ExternalLinkCaptureExtension } from '../extensions/external-link-capture';
import { CollaborationCursorLabelFitExtension } from '../extensions/collaboration-cursor-label-fit';
+import { FailedUploadCleanupExtension } from '../extensions/failed-upload-cleanup';
import { HeadingLinkExtension } from '../extensions/heading-link';
import { filterSuggestionItems } from '@blocknote/core/extensions';
import { withCollaboration } from '@blocknote/core/yjs';
@@ -100,6 +101,7 @@ export function OpBlockNoteEditor({
extensions: [
ExternalLinkA11yExtension,
HeadingLinkExtension,
+ ...(attachmentsEnabled ? [FailedUploadCleanupExtension] : []),
...(captureExternalLinks ? [ExternalLinkCaptureExtension] : []),
...(hocuspocusProvider ? [CollaborationCursorLabelFitExtension] : []),
],
diff --git a/frontend/src/react/extensions/failed-upload-cleanup.spec.ts b/frontend/src/react/extensions/failed-upload-cleanup.spec.ts
new file mode 100644
index 000000000000..dee1bd617eaf
--- /dev/null
+++ b/frontend/src/react/extensions/failed-upload-cleanup.spec.ts
@@ -0,0 +1,136 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { BlockNoteEditor, PartialBlock } from '@blocknote/core';
+import { FailedUploadCleanupExtension } from './failed-upload-cleanup';
+
+const UPLOADED_URL = 'https://openproject.example/attachments/1/content';
+
+const cleanups:(() => void)[] = [];
+
+function mountEditor(uploadFile:(file:File) => Promise, initialContent?:PartialBlock[]) {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const editor = BlockNoteEditor.create({ initialContent, uploadFile, extensions: [FailedUploadCleanupExtension] });
+ editor.mount(container);
+ cleanups.push(() => {
+ editor.unmount();
+ container.remove();
+ });
+
+ return { editor, container };
+}
+
+function filesTransfer():DataTransfer {
+ const transfer = new DataTransfer();
+ transfer.items.add(new File(['content'], 'notes.odt', { type: 'application/vnd.oasis.opendocument.text' }));
+ return transfer;
+}
+
+function dropFile(container:HTMLElement):void {
+ const target = container.querySelector('.bn-block-content')!;
+ const { left, top } = target.getBoundingClientRect();
+ target.dispatchEvent(new DragEvent('drop', {
+ dataTransfer: filesTransfer(), clientX: left + 5, clientY: top + 2, bubbles: true, cancelable: true,
+ }));
+}
+
+function pasteFile(editor:BlockNoteEditor):void {
+ editor.prosemirrorView.dom.dispatchEvent(new ClipboardEvent('paste', {
+ clipboardData: filesTransfer(), bubbles: true, cancelable: true,
+ }));
+}
+
+const settled = () => new Promise((resolve) => { setTimeout(resolve, 50); });
+const blockTypes = (editor:BlockNoteEditor) => editor.document.map((block) => block.type);
+
+describe('FailedUploadCleanupExtension', () => {
+ afterEach(() => {
+ cleanups.splice(0).forEach((cleanup) => cleanup());
+ });
+
+ it('removes the placeholder of a dropped file whose upload failed', async () => {
+ const { editor, container } = mountEditor(() => Promise.resolve(''), [{ type: 'paragraph', content: 'Text' }]);
+
+ dropFile(container);
+ await settled();
+
+ expect(blockTypes(editor)).toEqual(['paragraph']);
+ expect(container.querySelector('.bn-file-loading-preview')).toBeNull();
+ });
+
+ it('keeps the block of a dropped file that was uploaded', async () => {
+ const { editor, container } = mountEditor(() => Promise.resolve(UPLOADED_URL), [{ type: 'paragraph', content: 'Text' }]);
+
+ dropFile(container);
+ await settled();
+
+ expect(editor.document.find((block) => block.type === 'file')?.props).toMatchObject({ url: UPLOADED_URL });
+ });
+
+ it('removes the placeholder of a pasted file whose upload failed', async () => {
+ const { editor, container } = mountEditor(() => Promise.resolve(''), [{ type: 'paragraph' }]);
+ editor.focus();
+
+ pasteFile(editor);
+ await settled();
+
+ expect(blockTypes(editor)).not.toContain('file');
+ expect(container.querySelector('.bn-file-loading-preview')).toBeNull();
+ });
+
+ it('keeps the block of a file panel upload that failed, which offers to add a file again', async () => {
+ const { editor, container } = mountEditor(() => Promise.resolve(''), [{ id: 'image', type: 'image' }, { type: 'paragraph' }]);
+ const file = new File(['content'], 'photo.png', { type: 'image/png' });
+
+ const url = await editor.uploadFile!(file, 'image');
+ editor.updateBlock('image', { props: { name: file.name, url: url as string } });
+ await settled();
+
+ expect(blockTypes(editor)).toEqual(['image', 'paragraph']);
+ expect(container.querySelector('.bn-add-file-button')).not.toBeNull();
+ });
+
+ it('leaves an empty file block nobody uploaded to', async () => {
+ const { editor } = mountEditor(() => Promise.resolve(''), [{ type: 'file' }, { type: 'paragraph' }]);
+
+ await settled();
+
+ expect(blockTypes(editor)).toEqual(['file', 'paragraph']);
+ });
+
+ it('does nothing once the editor is gone', async () => {
+ let finishUpload:((url:string) => void)|undefined;
+ const { container } = mountEditor(() => new Promise((resolve) => { finishUpload = resolve; }), [{ type: 'paragraph' }]);
+ dropFile(container);
+
+ cleanups.splice(0).forEach((cleanup) => cleanup());
+ finishUpload!('');
+
+ await expect(settled()).resolves.toBeUndefined();
+ });
+});
diff --git a/frontend/src/react/extensions/failed-upload-cleanup.ts b/frontend/src/react/extensions/failed-upload-cleanup.ts
new file mode 100644
index 000000000000..42aa970200ae
--- /dev/null
+++ b/frontend/src/react/extensions/failed-upload-cleanup.ts
@@ -0,0 +1,44 @@
+//-- copyright
+// OpenProject is an open source project management software.
+// Copyright (C) the OpenProject GmbH
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License version 3.
+//
+// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+// Copyright (C) 2006-2013 Jean-Philippe Lang
+// Copyright (C) 2010-2013 the ChiliProject Team
+//
+// This program is free software; you can redistribute it and/or
+// modify it under the terms of the GNU General Public License
+// as published by the Free Software Foundation; either version 2
+// of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+//
+// See COPYRIGHT and LICENSE files for more details.
+//++
+
+import { createExtension } from '@blocknote/core';
+
+// A file block turns into "Loading..." when its upload starts and only renders again once
+// its props change. After a failed drop or paste nothing changes, so the placeholder would
+// stay stuck (https://github.com/TypeCellOS/BlockNote/issues/3246). BlockNote updates the
+// block after the upload ends, hence the timeout.
+export const FailedUploadCleanupExtension = createExtension(({ editor }) => ({
+ key: 'failedUploadCleanup',
+ mount: ({ signal }) => editor.onUploadEnd((blockId) => {
+ if (!blockId) return;
+
+ setTimeout(() => {
+ const stuck = !signal.aborted && editor.domElement?.querySelector(`[data-id="${blockId}"] .bn-file-loading-preview`);
+ if (stuck) editor.removeBlocks([blockId]);
+ });
+ }),
+}))();
diff --git a/modules/documents/spec/features/attachment_upload_spec.rb b/modules/documents/spec/features/attachment_upload_spec.rb
index bd8f294883d0..07b52ecdc906 100644
--- a/modules/documents/spec/features/attachment_upload_spec.rb
+++ b/modules/documents/spec/features/attachment_upload_spec.rb
@@ -169,6 +169,16 @@
expect(editor.element).to have_no_css("img[alt='image.png']")
end.not_to change { document.attachments.count }
end
+
+ it "leaves no placeholder behind for a dropped file" do
+ expect do
+ editor.drop_file(image_fixture.path)
+ expect(page).to have_text I18n.t("activerecord.errors.models.attachment.attributes.content_type.not_allowlisted",
+ value: "image/png")
+ expect(editor.element).to have_no_css("[data-content-type='image']")
+ expect(editor.element).to have_no_text("Loading...")
+ end.not_to change { document.attachments.count }
+ end
end
end
diff --git a/spec/support/form_fields/primerized/block_note_editor_input.rb b/spec/support/form_fields/primerized/block_note_editor_input.rb
index 15572d2e79bf..a5ab47e182bc 100644
--- a/spec/support/form_fields/primerized/block_note_editor_input.rb
+++ b/spec/support/form_fields/primerized/block_note_editor_input.rb
@@ -163,6 +163,31 @@ def paste_links(*links)
JS
end
+ # Capybara's own `drop` sends no coordinates, and BlockNote places a dropped file
+ # by them, so it would ignore the drop.
+ def drop_file(path)
+ input_id = "block-note-drop-file"
+ page.execute_script(<<~JS, input_id)
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.id = arguments[0];
+ document.body.appendChild(input);
+ JS
+ page.find_by_id(input_id, visible: :all).set(path)
+
+ page.execute_script(<<~JS, element.native, input_id)
+ const input = document.getElementById(arguments[1]);
+ const block = arguments[0].querySelector('.bn-block-content');
+ const { left, top, height } = block.getBoundingClientRect();
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(input.files[0]);
+ input.remove();
+ block.dispatchEvent(new DragEvent('drop', {
+ dataTransfer, clientX: left + 1, clientY: top + height / 2, bubbles: true, cancelable: true
+ }));
+ JS
+ end
+
def element
shadow_root.find("div[role='textbox']")
end