mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user