Skip to content
64 changes: 63 additions & 1 deletion packages/core/src/editor/managers/StateManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** @vitest-environment jsdom */
import { Plugin } from "prosemirror-state";
import { Plugin, TextSelection } from "prosemirror-state";
import {
afterEach,
beforeEach,
Expand All @@ -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";

Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions packages/diagram-block/src/block/sourcePopup.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BlockNoteViewRaw editor={editor} editable={editable} />);
});

const preview = await vi.waitFor(() => {
const preview = host.querySelector<HTMLElement>("[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();
}
},
);
169 changes: 169 additions & 0 deletions packages/math-block/src/inlineContent/sourcePopup.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BlockNoteViewRaw editor={editor} editable={editable} />);
});

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();
}
});
Loading