From 4308a6bbaf9a40af79564f0e47eeab6962320157 Mon Sep 17 00:00:00 2001 From: yousefed Date: Thu, 22 Oct 2026 17:12:42 +0200 Subject: [PATCH 1/2] fix(core): show the drag preview in Firefox and Safari, and draw it like the editor - The preview was hidden with `opacity: 0.001`. Chrome ignores this opacity in its snapshot, but Firefox and Safari keep it, so they showed no preview. The preview is now off-screen instead. - The preview copies the theme of the editor's container (classes, color scheme, theme variables), the editor's classes and the width of the dragged blocks. Thus images have their size in the editor, text wraps as in the editor, and tables have their grid. - The layout of the preview is inline, so app CSS that sizes the editor (e.g. a height on `.bn-editor`) does not apply to it. Its height is limited, so a tall preview does not reach into the page. - The toggle drop test waits for the highlight before it releases the mouse, so it does not release too early under load. --- packages/core/src/editor/editor.css | 22 ----- .../core/src/extensions/SideMenu/dragging.ts | 84 ++++++++++++------- .../src/end-to-end/dragdrop/dragdrop.test.tsx | 38 +++++++++ 3 files changed, 92 insertions(+), 52 deletions(-) diff --git a/packages/core/src/editor/editor.css b/packages/core/src/editor/editor.css index 6aeaea0d33..be9ed67002 100644 --- a/packages/core/src/editor/editor.css +++ b/packages/core/src/editor/editor.css @@ -66,28 +66,6 @@ pointer-events: none; } -.bn-drag-preview { - position: absolute; - top: 0; - left: 0; - padding: 10px; - /* Sort of a hack but seems like the most reliable solution. */ - /* Drag preview element needs to be within bounds of the document area or it - won't work in some cases. */ - /* Negative z-index covers most cases, but the element can still be visible - if UI elements are translucent. */ - /* Setting opacity has no effect on the drag preview but does affect the - element. Unless it's set to 0, in which case the drag preview also becomes - hidden. So setting it to an extremely low value instead makes the element - functionally invisible while not affecting the drag preview itself. */ - opacity: 0.001; - /* The element is kept in the DOM after setDragImage captures its snapshot, - so it can overlap the editor and block drops in that area. Disabling - pointer-events lets drag/drop events pass through to the editor while - leaving the captured preview unaffected. */ - pointer-events: none; -} - .bn-editor .bn-collaboration-cursor__base { position: relative; } diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index f90f2cf55a..90ed181516 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -61,49 +61,73 @@ function setDragImage(view: EditorView, from: number, to = from) { to += view.state.doc.resolve(from + 1).node().nodeSize; } - // The preview is a copy of the element that holds the dragged blocks, - // without its other children: a copy of each dragged block's element. The - // holding element can have no box of its own (`display: contents`, e.g. the - // slot of a React layout block), but the preview needs one. - const preview = view.domAtPos(from).node.cloneNode(false) as HTMLElement; - preview.style.removeProperty("display"); + // A copy of the element that holds the dragged blocks, without its other + // children: a copy of each dragged block's element. The holding element can + // have no box of its own (`display: contents`, e.g. the slot of a React + // layout block), but the preview needs one. + const blocks = view.domAtPos(from).node.cloneNode(false) as HTMLElement; + blocks.style.removeProperty("display"); for (let pos = from; pos < to; pos += view.state.doc.nodeAt(pos)!.nodeSize) { const blockDOM = view.nodeDOM(pos); if (!blockDOM) { throw new Error(`The dragged block at ${pos} has no DOM.`); } - preview.appendChild(blockDOM.cloneNode(true)); + blocks.appendChild(blockDOM.cloneNode(true)); } - // dataTransfer.setDragImage(element) only works if element is attached to the DOM. - unsetDragImage(view.root); - dragImageElement = preview; - // Browsers may have CORS policies which prevents iframes from being // manipulated, so better to stay on the safe side and remove them from the // drag preview. The drag preview doesn't work with embedded documents // (iframe/embed/object) anyway, and including an (e.g. a PDF) // can prevent the drag from initiating at all. - const embeddedDocs = dragImageElement.querySelectorAll( - "iframe, embed, object", - ); - embeddedDocs.forEach((el) => el.parentElement?.removeChild(el)); - - // TODO: This is hacky, need a better way of assigning classes to the editor so that they can also be applied to the - // drag preview. - const classes = view.dom.className.split(" "); - const inheritedClasses = classes - .filter( - (className) => - className !== "ProseMirror" && - className !== "bn-root" && - className !== "bn-editor", - ) - .join(" "); - - dragImageElement.className = - dragImageElement.className + " bn-drag-preview " + inheritedClasses; + blocks + .querySelectorAll("iframe, embed, object") + .forEach((el) => el.parentElement?.removeChild(el)); + + // The preview is outside the editor, so it copies what styles the blocks + // there: the theme of the editor's container (classes, color scheme, theme + // variables), the editor's classes, and the blocks' width, so that text + // wraps and images scale as in the editor. + const container = view.dom.parentElement?.closest(".bn-root"); + const preview = + (container?.cloneNode(false) as HTMLElement | undefined) ?? + document.createElement("div"); + preview.removeAttribute("id"); + preview.classList.add("bn-drag-preview"); + const editor = document.createElement("div"); + editor.className = view.dom.className; + editor.appendChild(blocks); + preview.appendChild(editor); + + // The layout is inline, so that app CSS on the copied classes (e.g. a height + // on `.bn-editor`) does not change it. The preview is off-screen, as the + // browser only needs it for its snapshot: `opacity` would also hide it in + // the snapshot in Firefox and Safari. Taller than the cap, it would reach + // into the page from above. + Object.assign(preview.style, { + position: "absolute", + top: "-10000px", + left: "0", + margin: "0", + padding: "10px", + height: "auto", + minHeight: "0", + maxHeight: "75vh", + overflow: "hidden", + }); + Object.assign(editor.style, { + width: `${(view.nodeDOM(from) as HTMLElement).offsetWidth}px`, + height: "auto", + minHeight: "0", + maxHeight: "none", + margin: "0", + padding: "0", + overflow: "visible", + }); + // dataTransfer.setDragImage(element) only works if element is attached to the DOM. + unsetDragImage(view.root); + dragImageElement = preview; if (view.root instanceof ShadowRoot) { view.root.appendChild(dragImageElement); } else { diff --git a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx index 472df7ad4b..dc98e87b6b 100644 --- a/tests/src/end-to-end/dragdrop/dragdrop.test.tsx +++ b/tests/src/end-to-end/dragdrop/dragdrop.test.tsx @@ -391,6 +391,42 @@ describe.skipIf(browserName === "firefox")("Dragging layout blocks", () => { } }); + // The preview is outside the editor, so it copies the editor's theme, + // classes and width, and app CSS that sizes the editor does not apply. + test("draws the drag preview like the editor", async () => { + const appCSS = document.createElement("style"); + appCSS.textContent = ".bn-editor { min-height: 500px; }"; + document.head.appendChild(appCSS); + await renderEditors([ + { + id: "table", + type: "table", + content: { type: "tableContent", rows: [{ cells: ["A", "B"] }] }, + }, + ]); + + await startBlockDrag(handleOf("table")); + try { + const preview = await waitForSelector(".bn-drag-preview"); + const copy = preview.querySelector('[data-id="table"]')!; + expect(copy.getBoundingClientRect().width).toBe( + block("table").getBoundingClientRect().width, + ); + expect(preview.getBoundingClientRect().height).toBeLessThan(500); + expect( + getComputedStyle(preview.querySelector("td")!).borderTopStyle, + ).toBe("solid"); + expect(preview.getAttribute("data-color-scheme")).toBe( + document + .querySelector(".bn-container")! + .getAttribute("data-color-scheme"), + ); + } finally { + await dropDraggedBlock(block("table"), true); + appCSS.remove(); + } + }); + test("moves a namedOnly layout block in its list, without a copy of the list", async () => { await renderEditors([ { @@ -454,6 +490,8 @@ describe.skipIf(browserName === "firefox")("Dropping into a block", () => { steps: 5, }, ]); + // The drop goes into the toggle once it is highlighted. + await waitForSelector("[data-drop-target]"); // Hovers before the drop, as a user does, which gave ProseMirror the time // to draw the toggle again. await sleep(200); From af3958d1778942aa23e2b57c8259fd71e6582403 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 27 Oct 2026 12:17:11 +0100 Subject: [PATCH 2/2] fix(core): remove editor background from drag preview --- packages/core/src/extensions/SideMenu/dragging.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core/src/extensions/SideMenu/dragging.ts b/packages/core/src/extensions/SideMenu/dragging.ts index 90ed181516..a6e8379a0e 100644 --- a/packages/core/src/extensions/SideMenu/dragging.ts +++ b/packages/core/src/extensions/SideMenu/dragging.ts @@ -105,6 +105,7 @@ function setDragImage(view: EditorView, from: number, to = from) { // the snapshot in Firefox and Safari. Taller than the cap, it would reach // into the page from above. Object.assign(preview.style, { + background: "transparent", position: "absolute", top: "-10000px", left: "0", @@ -116,6 +117,8 @@ function setDragImage(view: EditorView, from: number, to = from) { overflow: "hidden", }); Object.assign(editor.style, { + // Keep the editor's background out of the empty space around the blocks. + background: "transparent", width: `${(view.nodeDOM(from) as HTMLElement).offsetWidth}px`, height: "auto", minHeight: "0",