diff --git a/packages/core/src/editor/managers/StateManager.test.ts b/packages/core/src/editor/managers/StateManager.test.ts
index 22eeed4dbc..00ac28c9d8 100644
--- a/packages/core/src/editor/managers/StateManager.test.ts
+++ b/packages/core/src/editor/managers/StateManager.test.ts
@@ -1,5 +1,5 @@
/** @vitest-environment jsdom */
-import { Plugin } from "prosemirror-state";
+import { Plugin, TextSelection } from "prosemirror-state";
import {
afterEach,
beforeEach,
@@ -8,6 +8,9 @@ import {
it,
vi,
} from "vite-plus/test";
+import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
+import { SourceInlineContentWithPreviewExtension } from "../../extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.js";
+import { createInlineContentSpec } from "../../schema/inlineContent/createSpec.js";
import { BlockNoteEditor } from "../BlockNoteEditor.js";
import { createExtension } from "../BlockNoteExtension.js";
@@ -26,6 +29,65 @@ describe("StateManager editability", () => {
prosemirrorPlugins: [new Plugin({ props: { editable: () => false } })],
}));
+ it.each(["application", "plugin"] as const)(
+ "reconciles inline popup state after remounting with the %s restriction",
+ (source) => {
+ editor.unmount();
+ const preview = createInlineContentSpec(
+ { type: "preview", propSchema: {}, content: "plain" },
+ {
+ meta: { hasPreview: true },
+ render() {
+ const dom = document.createElement("span");
+ return { dom, contentDOM: dom };
+ },
+ },
+ );
+ const schema = BlockNoteSchema.create().extend({
+ inlineContentSpecs: { preview },
+ });
+ const previewEditor = BlockNoteEditor.create({
+ schema,
+ extensions: source === "plugin" ? [restriction()] : [],
+ initialContent: [
+ { type: "paragraph", content: [{ type: "preview", content: "x" }] },
+ ],
+ });
+ const host = document.createElement("div");
+ try {
+ previewEditor.mount(host);
+ let previewPos = -1;
+ previewEditor.prosemirrorState.doc.descendants((node, pos) => {
+ if (node.type.name === "preview") {
+ previewPos = pos;
+ }
+ });
+ expect(previewPos).toBeGreaterThan(0);
+ previewEditor.transact((tr) =>
+ tr.setSelection(TextSelection.create(tr.doc, previewPos + 1)),
+ );
+ const popup = previewEditor.getExtension(
+ SourceInlineContentWithPreviewExtension,
+ )!;
+ expect(popup.store.state.selected).toBe(
+ source === "plugin" ? undefined : previewPos,
+ );
+ previewEditor.unmount();
+ if (source === "application") {
+ previewEditor.isEditable = false;
+ }
+ previewEditor.mount(host);
+ expect(previewEditor.isEditable).toBe(false);
+ expect(previewEditor.prosemirrorState.selection.from).toBe(
+ previewPos + 1,
+ );
+ expect(popup.store.state.selected).toBeUndefined();
+ } finally {
+ previewEditor.unmount();
+ }
+ },
+ );
+
it.each(["mount", "register", "replace", "unregister"] as const)(
"allows editability subscribers to register plugins during %s without duplicate views",
(operation) => {
diff --git a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts
index 9ab67e93a7..f15c5ce470 100644
--- a/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts
+++ b/packages/core/src/extensions/SourceInlineContentWithPreview/SourceInlineContentWithPreview.ts
@@ -14,7 +14,8 @@ import {
* rather than the extension being configured with a type.
*
* Unlike the block version, the popup isn't toggled with a separate state flag:
- * it's open exactly when the selection is inside the inline content's source.
+ * it's open when the editor is editable and the selection is inside the inline
+ * content's source.
* The store therefore only tracks which inline content (by its position) holds
* the selection - moving the selection in opens its popup, moving it out closes
* it. Since the source popup is always laid out (just hidden via opacity), the
@@ -38,7 +39,7 @@ export const SourceInlineContentWithPreviewExtension = createExtension(
?.hasPreview;
// Moves the selection out of the inline content, to just `"before"` or
- // `"after"` it, which closes the popup via the selection-change handler
+ // `"after"` it, which closes the popup via the selection listener
// below. Lets the keyboard commit-and-exit the source the same way arrowing
// past its edge does, keeps Enter from splitting the block while editing the
// source, and lets the up/down arrows step out of the source rather than
@@ -92,19 +93,31 @@ export const SourceInlineContentWithPreviewExtension = createExtension(
},
},
mount: ({ dom, signal }) => {
- // The popup is open exactly when the selection is inside the inline
- // content, so we just track which inline content (if any) holds it.
- const unsubscribeSelectionChange = editor.onSelectionChange(() => {
- const { $from } = editor.prosemirrorState.selection;
- const node = $from.node();
-
- store.setState({
- selected: nodeHasPreview(node.type.name)
- ? $from.before()
- : undefined,
+ function updateSelected() {
+ if (!editor.isEditable) {
+ store.setState({ selected: undefined });
+ return;
+ }
+ editor.transact((tr) => {
+ const { $from } = tr.selection;
+ store.setState({
+ selected: nodeHasPreview($from.node().type.name)
+ ? $from.before()
+ : undefined,
+ });
});
- });
- signal.addEventListener("abort", unsubscribeSelectionChange);
+ }
+ signal.addEventListener(
+ "abort",
+ editor.onSelectionChange(updateSelected),
+ );
+ signal.addEventListener(
+ "abort",
+ editor.onEditableChange(updateSelected),
+ );
+ // Selection and editability may have changed while this extension was
+ // unmounted, when neither subscription was attached.
+ updateSelected();
// Sets `visibility: hidden` on the popup for a single frame when pressing up/down arrow
// keys. The popup is normally hidden through `opacity: 0`, which means it's still visible
diff --git a/packages/diagram-block/src/block/sourcePopup.browser.test.tsx b/packages/diagram-block/src/block/sourcePopup.browser.test.tsx
new file mode 100644
index 0000000000..86a397ad41
--- /dev/null
+++ b/packages/diagram-block/src/block/sourcePopup.browser.test.tsx
@@ -0,0 +1,73 @@
+import "@blocknote/core/style.css";
+
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
+import { SourceBlockWithPreviewExtension } from "@blocknote/core/extensions";
+import { BlockNoteViewRaw } from "@blocknote/react";
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+
+import { createReactDiagramBlockSpec } from "./createReactDiagramBlockSpec.js";
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: { diagram: createReactDiagramBlockSpec() },
+});
+
+it.each([false, true])(
+ "only opens a diagram preview on click when editable (editable: %s)",
+ async (editable) => {
+ const editor = BlockNoteEditor.create({
+ schema,
+ trailingBlock: false,
+ initialContent: [
+ { id: "diagram", type: "diagram", content: "graph TD; A --> B" },
+ ],
+ });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+
+ try {
+ flushSync(() => {
+ root.render();
+ });
+
+ const preview = await vi.waitFor(() => {
+ const preview = host.querySelector("[role='img']");
+ if (!preview) {
+ throw new Error("Diagram preview not rendered yet");
+ }
+ return preview;
+ });
+ const extension = editor.getExtension(SourceBlockWithPreviewExtension)!;
+ flushSync(() => editor.setTextCursorPosition("diagram", "end"));
+ expect(extension.store.state.selected).toBe("diagram");
+ // Unlike inline math, selecting a diagram never opens its popup.
+ expect(extension.store.state.popupOpen).toBeUndefined();
+
+ await userEvent.click(preview);
+ expect(extension.store.state.popupOpen).toBe(
+ editable ? "diagram" : undefined,
+ );
+ await vi.waitFor(() =>
+ expect(
+ host
+ .querySelector(".bn-preview-with-source-popup")
+ ?.getAttribute("data-open"),
+ ).toBe(String(editable)),
+ );
+
+ if (!editable) {
+ const content = editor.getBlock("diagram")?.content;
+ await userEvent.keyboard("{Enter}");
+ expect(extension.store.state.popupOpen).toBeUndefined();
+ expect(editor.getBlock("diagram")?.content).toEqual(content);
+ }
+ } finally {
+ root.unmount();
+ editor._tiptapEditor.destroy();
+ host.remove();
+ }
+ },
+);
diff --git a/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx
new file mode 100644
index 0000000000..eecf915eb0
--- /dev/null
+++ b/packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx
@@ -0,0 +1,169 @@
+import "@blocknote/core/style.css";
+
+import {
+ BlockNoteEditor,
+ BlockNoteSchema,
+ createExtension,
+} from "@blocknote/core";
+import { SourceInlineContentWithPreviewExtension } from "@blocknote/core/extensions";
+import { BlockNoteViewRaw } from "@blocknote/react";
+import { Plugin, TextSelection } from "prosemirror-state";
+import { flushSync } from "react-dom";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vite-plus/test";
+
+import { createReactInlineMathSpec } from "./createReactMathInlineContentSpec.js";
+
+const schema = BlockNoteSchema.create().extend({
+ inlineContentSpecs: { math: createReactInlineMathSpec() },
+});
+
+it.each([
+ { editable: false, batched: false, pluginReadOnly: false },
+ { editable: true, batched: false, pluginReadOnly: false },
+ { editable: true, batched: true, pluginReadOnly: false },
+ { editable: true, batched: false, pluginReadOnly: true },
+ { editable: true, batched: true, pluginReadOnly: true },
+])(
+ "inline math popup follows editability (#3291): %j",
+ async ({ editable, batched, pluginReadOnly }) => {
+ const pluginEditability = createExtension(() => ({
+ key: "testPluginEditability",
+ prosemirrorPlugins: [
+ new Plugin({ props: { editable: () => !pluginReadOnly } }),
+ ],
+ }));
+ const editor = BlockNoteEditor.create({
+ schema,
+ extensions: [pluginEditability()],
+ trailingBlock: false,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: [{ type: "math", content: "x^2" }],
+ },
+ ],
+ });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+
+ try {
+ flushSync(() => {
+ root.render();
+ });
+
+ let mathPos: number | undefined;
+ editor.prosemirrorState.doc.descendants((node, pos) => {
+ if (node.type.name === "math") {
+ mathPos = pos;
+ }
+ });
+ if (mathPos === undefined) {
+ throw new Error("Inline math node not found");
+ }
+ const sourcePos = mathPos + 1;
+
+ async function expectPopup(open: boolean) {
+ expect(editor.prosemirrorState.selection.from).toBe(sourcePos);
+ expect(
+ editor.getExtension(SourceInlineContentWithPreviewExtension)!.store
+ .state.selected,
+ ).toBe(open ? mathPos : undefined);
+ await vi.waitFor(() =>
+ expect(
+ host
+ .querySelector(".bn-preview-with-source-popup")
+ ?.getAttribute("data-open"),
+ ).toBe(String(open)),
+ );
+ }
+
+ // Read-only ProseMirror views still move the selection on click. Exercise
+ // that selection path directly, independently of the guarded preview click.
+ flushSync(() => {
+ editor.transact((tr) =>
+ tr.setSelection(TextSelection.create(tr.doc, sourcePos)),
+ );
+ });
+
+ await expectPopup(editable && !pluginReadOnly);
+
+ if (editable) {
+ flushSync(() => {
+ if (batched) {
+ editor.transact(() => {
+ editor.isEditable = false;
+ });
+ } else {
+ editor.isEditable = false;
+ }
+ });
+
+ await expectPopup(false);
+
+ flushSync(() => {
+ if (batched) {
+ editor.transact(() => {
+ editor.isEditable = true;
+ });
+ } else {
+ editor.isEditable = true;
+ }
+ });
+
+ await expectPopup(!pluginReadOnly);
+ }
+ } finally {
+ root.unmount();
+ editor._tiptapEditor.destroy();
+ host.remove();
+ }
+ },
+);
+
+it("tracks inline source popups through the editor API", () => {
+ const editor = BlockNoteEditor.create({
+ schema,
+ initialContent: [
+ { type: "paragraph", content: [{ type: "math", content: "x^2" }] },
+ ],
+ });
+ const host = document.createElement("div");
+ document.body.append(host);
+
+ try {
+ editor.mount(host);
+ expect(editor.prosemirrorView).toBeDefined();
+ let mathPos: number | undefined;
+ editor.prosemirrorState.doc.descendants((node, pos) => {
+ if (node.type.name === "math") {
+ mathPos = pos;
+ }
+ });
+ if (mathPos === undefined) {
+ throw new Error("Inline math node not found");
+ }
+ const sourcePos = mathPos + 1;
+ editor.transact((tr) =>
+ tr.setSelection(TextSelection.create(tr.doc, sourcePos)),
+ );
+ expect(
+ editor.getExtension(SourceInlineContentWithPreviewExtension)!.store.state
+ .selected,
+ ).toBe(mathPos);
+ editor.isEditable = false;
+ expect(
+ editor.getExtension(SourceInlineContentWithPreviewExtension)!.store.state
+ .selected,
+ ).toBeUndefined();
+ editor.isEditable = true;
+ expect(
+ editor.getExtension(SourceInlineContentWithPreviewExtension)!.store.state
+ .selected,
+ ).toBe(mathPos);
+ } finally {
+ editor._tiptapEditor.destroy();
+ host.remove();
+ }
+});