diff --git a/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx index 65be1cfa13..feda337ac1 100644 --- a/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx +++ b/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx @@ -1,5 +1,5 @@ import { BlockNoteEditor } from "@blocknote/core"; -import { MouseEvent, ReactNode, useId, useRef } from "react"; +import { MouseEvent, ReactNode, useCallback, useId, useRef } from "react"; import { MdKeyboardReturn } from "react-icons/md"; import { PreviewPlaceholder } from "./PreviewPlaceholder.js"; @@ -95,6 +95,42 @@ export const SourceWithPreview = ( sourcePlaceholder, } = props; + // Clean up on ref detachment rather than returning a cleanup (React 18 support). + const errorCleanupRef = useRef<(() => void) | undefined>(undefined); + const errorRef = useCallback( + (node: HTMLDivElement | null) => { + errorCleanupRef.current?.(); + errorCleanupRef.current = undefined; + if (!node || !popup.isOpen) { + return; + } + const element = node; + const document = element.ownerDocument; + // These events target the document or the focused editor, not the error + // element. Keep ProseMirror from reading a native selection inside the + // non-editable error as a document selection or copying the source instead. + function stopErrorSelectionEvent(event: Event) { + const selection = document.getSelection(); + if ( + element.contains(selection?.anchorNode ?? null) && + element.contains(selection?.focusNode ?? null) + ) { + event.stopImmediatePropagation(); + } + } + const events = ["selectionchange", "copy", "cut", "paste", "keydown"]; + for (const event of events) { + document.addEventListener(event, stopErrorSelectionEvent, true); + } + errorCleanupRef.current = () => { + for (const event of events) { + document.removeEventListener(event, stopErrorSelectionEvent, true); + } + }; + }, + [popup.isOpen], + ); + // Whether the error has been "committed": shown while the popup was // closed. The last-good preview only stays up within the editing session // that introduced the error - once committed, the error state also shows @@ -234,10 +270,14 @@ export const SourceWithPreview = (
event.stopPropagation()} // Announced while editing (the popup is open); silenced & removed // from the tree once the popup closes, where the compact error // preview takes over. diff --git a/tests/src/end-to-end/sourcepreview/errorSelection.test.tsx b/tests/src/end-to-end/sourcepreview/errorSelection.test.tsx new file mode 100644 index 0000000000..f21eb600f1 --- /dev/null +++ b/tests/src/end-to-end/sourcepreview/errorSelection.test.tsx @@ -0,0 +1,147 @@ +import { BlockNoteSchema } from "@blocknote/core"; +import "@blocknote/core/fonts/inter.css"; +import { createReactDiagramBlockSpec } from "@blocknote/diagram-block"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { + createReactInlineMathSpec, + createReactMathBlockSpec, +} from "@blocknote/math-block"; +import { useCreateBlockNote } from "@blocknote/react"; +import { expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { browserName, MOD, userEvent } from "../../utils/context.js"; +import { mouseSequence } from "../../utils/mouse.js"; + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + mathBlock: createReactMathBlockSpec(), + diagram: createReactDiagramBlockSpec(), + }, + inlineContentSpecs: { math: createReactInlineMathSpec() }, +}); + +type Kind = "mathBlock" | "diagram" | "math"; + +function ErrorSelectionApp({ kind }: { kind: Kind }) { + const editor = useCreateBlockNote({ + schema, + initialContent: + kind === "math" + ? [ + { + type: "paragraph", + content: [{ type: "math", content: "\\badcommand" }], + }, + ] + : [ + { + type: kind, + content: kind === "diagram" ? "graph TD\nA -->" : "\\badcommand", + }, + ], + }); + return ( + <> + +