diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index d04c171c7d..6499cb5b5a 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react"; import * as React from "react"; import { DndProvider } from "react-dnd"; -import { HTML5Backend } from "react-dnd-html5-backend"; import { useLocation } from "react-router-dom"; +import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend"; import ErrorSuspended from "~/scenes/Errors/ErrorSuspended"; import Layout from "~/components/Layout"; import RegisterKeyDown from "~/components/RegisterKeyDown"; @@ -106,7 +106,7 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { - + diff --git a/app/components/EditorAwareHTML5Backend.ts b/app/components/EditorAwareHTML5Backend.ts new file mode 100644 index 0000000000..a80c27aea2 --- /dev/null +++ b/app/components/EditorAwareHTML5Backend.ts @@ -0,0 +1,63 @@ +import type { BackendFactory } from "dnd-core"; +import { HTML5Backend } from "react-dnd-html5-backend"; + +/** + * react-dnd's HTML5 backend installs global capture-phase listeners on `window` + * that call `preventDefault()` on drops whose dataTransfer resembles a native + * item – including a dragged ``, which is how ProseMirror serializes an + * image drag. + * + * These handlers run before ProseMirror's, and they live on `window`, so a + * propagation-based guard can't stop react-dnd without also starving the editor + * of the event. Instead we wrap the backend and make its top-level capture + * handlers no-op for events that occur within the editor surface. + */ +const captureHandlerNames = [ + "handleTopDragStartCapture", + "handleTopDragEnterCapture", + "handleTopDragOverCapture", + "handleTopDragLeaveCapture", + "handleTopDropCapture", + "handleTopDragEndCapture", +] as const; + +const isWithinEditor = (target: EventTarget | null): boolean => + target instanceof Element && Boolean(target.closest(".ProseMirror")); + +/** + * An HTML5 drag-and-drop backend that ignores drag events originating within the + * rich text editor so that ProseMirror can handle them itself. + * + * @param manager The drag-and-drop manager. + * @param context The global context. + * @param options Backend options. + * @returns The wrapped HTML5 backend instance. + */ +export const EditorAwareHTML5Backend: BackendFactory = ( + manager, + context, + options +) => { + const backend = HTML5Backend(manager, context, options); + + // The capture handlers are private instance fields on the backend, so reach + // for them through an index signature view of the instance. + const handlers = backend as unknown as Record< + string, + (event: DragEvent) => void + >; + + for (const name of captureHandlerNames) { + const original = handlers[name]; + if (typeof original === "function") { + handlers[name] = (event: DragEvent) => { + if (isWithinEditor(event.target)) { + return; + } + original.call(backend, event); + }; + } + } + + return backend; +}; diff --git a/shared/editor/components/Caption.tsx b/shared/editor/components/Caption.tsx index 56c9c4a29c..f2b7dd6bb0 100644 --- a/shared/editor/components/Caption.tsx +++ b/shared/editor/components/Caption.tsx @@ -46,6 +46,7 @@ function Caption({ placeholder, children, isSelected, width, ...rest }: Props) { tabIndex={-1} aria-label={t("Caption")} role="textbox" + draggable={false} contentEditable suppressContentEditableWarning data-caption={placeholder} diff --git a/shared/editor/nodes/Image.tsx b/shared/editor/nodes/Image.tsx index 18f6672768..97bbc318d1 100644 --- a/shared/editor/nodes/Image.tsx +++ b/shared/editor/nodes/Image.tsx @@ -132,8 +132,7 @@ export default class Image extends SimpleImage { marks: "", group: "inline", selectable: true, - // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1289000 - draggable: false, + draggable: true, atom: true, parseDOM: [ { @@ -260,6 +259,32 @@ export default class Image extends SimpleImage { commentedImagePlugin(), new Plugin({ props: { + handleDOMEvents: { + dragstart: (_view, event) => { + // ProseMirror lets the browser snapshot the dragged node's DOM as + // the drag image. For images that DOM includes the caption area and + // padding, which renders as a large white box around the image. + // Substitute the image element so the drag ghost is tight to it. + if ( + !(event.target instanceof HTMLElement) || + !event.dataTransfer + ) { + return false; + } + const image = event.target + .closest(`.component-${this.name}`) + ?.querySelector("img"); + if (image) { + const rect = image.getBoundingClientRect(); + event.dataTransfer.setDragImage( + image, + event.clientX - rect.left, + event.clientY - rect.top + ); + } + return false; + }, + }, handleKeyDown: (view, event) => { // prevent prosemirror's default spacebar behavior // & zoom in if the selected node is image