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 (
+ <>
+
+
+ >
+ );
+}
+
+test.each(["mathBlock", "diagram", "math"])(
+ "selecting and copying %s error text preserves the popup and source editing",
+ async (kind) => {
+ await render();
+ await userEvent.click(
+ document.querySelector(".bn-preview-container")!,
+ );
+ const error = document.querySelector(
+ ".bn-code-block-source-error",
+ )!;
+ await vi.waitFor(() => {
+ expect(error.textContent?.length).toBeGreaterThan(0);
+ expect(
+ error
+ .closest(".bn-preview-with-source-popup")
+ ?.getAttribute("data-open"),
+ ).toBe("true");
+ });
+
+ // Drag across the first word using real mouse events. A DOM-only selection
+ // wouldn't reproduce Firefox resetting the selection after mouseup (#3138).
+ await userEvent.hover(error);
+ const text = error.firstChild!;
+ const word = (text.textContent ?? "").match(/\S+/)![0];
+ const range = document.createRange();
+ range.setStart(text, 0);
+ range.setEnd(text, word.length);
+ const rect = range.getBoundingClientRect();
+ await mouseSequence([
+ { type: "move", x: rect.left, y: rect.top + rect.height / 2 },
+ { type: "down" },
+ { type: "move", x: rect.right, y: rect.top + rect.height / 2, steps: 10 },
+ { type: "up" },
+ ]);
+ await vi.waitFor(() => {
+ expect(window.getSelection()?.toString()).toBe(word);
+ expect(
+ error
+ .closest(".bn-preview-with-source-popup")
+ ?.getAttribute("data-open"),
+ ).toBe("true");
+ });
+ const source = document.querySelector(
+ ".bn-source-block-popup code",
+ )!;
+ const originalSource = source.textContent;
+ // The editor still has focus, but its shortcuts must not edit or submit
+ // the source while the native selection belongs to the error text.
+ await userEvent.keyboard("{Enter}{Backspace}");
+ expect(source.textContent).toBe(originalSource);
+ expect(
+ error.closest(".bn-preview-with-source-popup")?.getAttribute("data-open"),
+ ).toBe("true");
+ // Playwright WebKit does not support the native clipboard round trip.
+ // Still exercise selection, focus recovery, and dismissal in that browser.
+ if (browserName !== "webkit") {
+ await userEvent.keyboard(`{${MOD}>}c{/${MOD}}`);
+ await userEvent.keyboard(`{${MOD}>}v{/${MOD}}`);
+ await userEvent.keyboard(`{${MOD}>}x{/${MOD}}`);
+ expect(source.textContent).toBe(originalSource);
+ }
+
+ // Focus leaving the editor must close the popup.
+ const textarea = document.querySelector("textarea")!;
+ await userEvent.click(textarea);
+ expect(
+ error.closest(".bn-preview-with-source-popup")?.getAttribute("data-open"),
+ ).toBe("false");
+ await userEvent.click(
+ document.querySelector(".bn-preview-container")!,
+ );
+ await userEvent.click(error);
+
+ // Moving directly back from the error to the source must keep it editable.
+ await userEvent.click(source);
+ await userEvent.keyboard("{End}x");
+ await vi.waitFor(() =>
+ expect(source.textContent).toBe(originalSource + "x"),
+ );
+ expect(
+ source
+ .closest(".bn-preview-with-source-popup")
+ ?.getAttribute("data-open"),
+ ).toBe("true");
+
+ await userEvent.click(textarea);
+ if (browserName !== "webkit") {
+ await userEvent.keyboard(`{${MOD}>}v{/${MOD}}`);
+ expect(textarea.value).toBe(word);
+ }
+ expect(
+ error.closest(".bn-preview-with-source-popup")?.getAttribute("data-open"),
+ ).toBe("false");
+ },
+);