fix: Crash when two mermaid diagrams are adjacent (#13173)

* fix: Crash when two mermaid diagrams are adjacent

A diagram's widget decoration sits at the end of its code block, which is
also the start position of the following block. When the next block is also
a mermaid diagram and has no decoration of its own yet, it adopted the
preceding diagram's renderer — and so its single DOM element — leaving two
widget decorations sharing one node and crashing prosemirror-view.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Enter with mermaid selection deletes node

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-07-28 08:58:01 -04:00
committed by GitHub
co-authored by Claude
parent c5f9646337
commit 5ff3020318
3 changed files with 59 additions and 14 deletions
+30 -3
View File
@@ -1,6 +1,7 @@
import { TextSelection } from "prosemirror-state";
import { createEditorState, codeBlock, doc } from "@shared/test/editor";
import { indentInCode, outdentInCode } from "./codeFence";
import { baseKeymap, chainCommands } from "prosemirror-commands";
import { NodeSelection, TextSelection } from "prosemirror-state";
import { createEditorState, codeBlock, doc, p } from "@shared/test/editor";
import { enterInCode, indentInCode, outdentInCode } from "./codeFence";
/**
* Helper that runs a command against a code block document with the given
@@ -135,3 +136,29 @@ describe("indentInCode", () => {
expect(text).toBe("a\n b\n c");
});
});
describe("enterInCode", () => {
it("adds a paragraph below when the code block is selected", () => {
const testDoc = doc([
p("before"),
codeBlock("graph TD; A-->B", "mermaidjs"),
]);
let state = createEditorState(testDoc);
const pos = state.doc.firstChild!.nodeSize;
state = state.apply(
state.tr.setSelection(NodeSelection.create(state.doc, pos))
);
const handled = chainCommands(enterInCode, baseKeymap.Enter)(
state,
(tr) => {
state = state.apply(tr);
}
);
expect(handled).toBe(true);
expect(state.doc.child(1).textContent).toBe("graph TD; A-->B");
expect(state.doc.lastChild?.type.name).toBe("paragraph");
expect(state.doc.childCount).toBe(3);
});
});
+4 -2
View File
@@ -1,6 +1,6 @@
import { exitCode } from "prosemirror-commands";
import type { Command, EditorState } from "prosemirror-state";
import { TextSelection } from "prosemirror-state";
import { NodeSelection, TextSelection } from "prosemirror-state";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
import { isInCode } from "../queries/isInCode";
import { findParentNode } from "../queries/findParentNode";
@@ -67,7 +67,9 @@ export const moveToNextNewline: Command = (state, dispatch) => {
* @returns A prosemirror command
*/
export const newlineInCode: Command = (state, dispatch) => {
if (!isInCode(state)) {
// A selected code block has no cursor to insert a newline at, and replacing
// it with one would delete the block.
if (state.selection instanceof NodeSelection || !isInCode(state)) {
return false;
}
const { tr, selection } = state;
+25 -9
View File
@@ -322,6 +322,7 @@ function getNewState({
autoEditEmpty?: boolean;
}): MermaidState {
const decorations: Decoration[] = [];
const usedRenderers = new Set<MermaidRenderer>();
let newEditingId: string | undefined;
// Find all blocks that represent Mermaid diagrams (supports both "mermaid" and "mermaidjs"),
@@ -331,11 +332,25 @@ function getNewState({
);
blocks.forEach((block) => {
const existingDecorations = pluginState.decorationSet.find(
block.pos,
block.pos + block.node.nodeSize,
(spec) => !!spec.diagramId
);
const existingDecorations = pluginState.decorationSet
.find(
block.pos,
block.pos + block.node.nodeSize,
(spec) => !!spec.diagramId
)
// A widget sitting exactly at the start of this block belongs to the
// preceding diagram, whose end position is shared with this one.
.filter((decoration) => {
if (
decoration.from === decoration.to &&
decoration.from === block.pos
) {
return false;
}
// Each renderer owns a single DOM element, so it can only back one
// diagram — reusing it would place the same node in two places.
return !usedRenderers.has(decoration.spec.renderer);
});
const bestDecoration = findBestOverlapDecoration(
existingDecorations,
@@ -345,6 +360,7 @@ function getNewState({
const isNewBlock = !bestDecoration;
const renderer: MermaidRenderer =
bestDecoration?.spec?.renderer ?? new MermaidRenderer(editor);
usedRenderers.add(renderer);
// Auto-enter edit mode for newly created empty mermaid diagrams
if (
@@ -355,16 +371,16 @@ function getNewState({
newEditingId = renderer.diagramId;
}
void renderer.render(block, pluginState.isDark);
const diagramDecoration = Decoration.widget(
block.pos + block.node.nodeSize,
() => {
void renderer.render(block, pluginState.isDark);
return renderer.element;
},
() => renderer.element,
{
diagramId: renderer.diagramId,
renderer,
side: -10,
key: `mermaid-${renderer.diagramId}`,
}
);