Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -234,10 +270,14 @@ export const SourceWithPreview = (
</div>
</div>
<div
ref={errorRef}
id={errorId}
className="bn-code-block-source-error"
contentEditable={false}
style={{ display: error ? "block" : "none" }}
// Stop ProseMirror's mouse handler before it can replace the native
// text selection. React's bubbling handler runs after ProseMirror's.
onMouseDownCapture={(event) => 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.
Expand Down
147 changes: 147 additions & 0 deletions tests/src/end-to-end/sourcepreview/errorSelection.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<BlockNoteView editor={editor} />
<textarea aria-label="Paste error text" />
</>
);
}

test.each<Kind>(["mathBlock", "diagram", "math"])(
"selecting and copying %s error text preserves the popup and source editing",
async (kind) => {
await render(<ErrorSelectionApp kind={kind} />);
await userEvent.click(
document.querySelector<HTMLElement>(".bn-preview-container")!,
);
const error = document.querySelector<HTMLElement>(
".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<HTMLElement>(
".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<HTMLTextAreaElement>("textarea")!;
await userEvent.click(textarea);
expect(
error.closest(".bn-preview-with-source-popup")?.getAttribute("data-open"),
).toBe("false");
await userEvent.click(
document.querySelector<HTMLElement>(".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");
},
);
Loading