diff --git a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
index c9e8648861..b3de102057 100644
--- a/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
+++ b/packages/core/src/extensions/SourceBlockWithPreview/SourceBlockWithPreview.ts
@@ -183,8 +183,19 @@ export const SourceBlockWithPreviewExtension = createExtension(
signal,
});
- const handleBlur = () =>
+ function handleBlur(event: FocusEvent) {
+ // Error text has its own focus target so the browser can select and
+ // copy it. Keep the popup open while interacting with that text.
+ if (
+ event.relatedTarget === dom ||
+ (event.relatedTarget instanceof Element &&
+ dom.contains(event.relatedTarget) &&
+ event.relatedTarget.closest(".bn-code-block-source-error"))
+ ) {
+ return;
+ }
store.setState((state) => ({ ...state, popupOpen: undefined }));
+ }
dom.addEventListener("blur", handleBlur, { capture: true, signal });
},
};
diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts
index 9ab67e93a7..e298bd38af 100644
--- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts
+++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts
@@ -134,7 +134,18 @@ export const SourceInlineContentWithPreviewExtension = createExtension(
signal,
});
- const handleBlur = () => store.setState({ selected: undefined });
+ function handleBlur(event: FocusEvent) {
+ // Keep the popup open while selecting error text or returning to the source.
+ if (
+ event.relatedTarget === dom ||
+ (event.relatedTarget instanceof Element &&
+ dom.contains(event.relatedTarget) &&
+ event.relatedTarget.closest(".bn-code-block-source-error"))
+ ) {
+ return;
+ }
+ store.setState({ selected: undefined });
+ }
dom.addEventListener("blur", handleBlur, { capture: true, signal });
},
};
diff --git a/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx b/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx
index 65be1cfa13..02084d814f 100644
--- a/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx
+++ b/packages/react/src/blocks/SourceWithPreview/SourceWithPreview.tsx
@@ -238,6 +238,9 @@ export const SourceWithPreview = (
className="bn-code-block-source-error"
contentEditable={false}
style={{ display: error ? "block" : "none" }}
+ // Let the browser focus the non-editable error so ProseMirror doesn't
+ // replace the browser's text selection with a source selection.
+ tabIndex={error ? -1 : undefined}
// 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..1a9ec069ec
--- /dev/null
+++ b/tests/src/end-to-end/sourcepreview/errorSelection.test.tsx
@@ -0,0 +1,138 @@
+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).
+ 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(document.activeElement).toBe(error);
+ expect(window.getSelection()?.toString()).toBe(word);
+ 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}}`);
+ }
+
+ // Focus leaving the error 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);
+ expect(document.activeElement).toBe(error);
+
+ // Moving directly back from the error to the source must keep it editable.
+ const source = document.querySelector(
+ ".bn-source-block-popup code",
+ )!;
+ await userEvent.click(source);
+ const originalSource = source.textContent;
+ 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");
+ },
+);