fix: Spurious post-import edits (#12620)

This commit is contained in:
Tom Moor
2026-06-07 21:13:23 -04:00
committed by GitHub
parent 7938ffdd7a
commit 053693b9d5
6 changed files with 240 additions and 46 deletions
+11 -37
View File
@@ -1,6 +1,9 @@
import type { NodeType } from "prosemirror-model";
import { Plugin, PluginKey } from "prosemirror-state";
import Extension from "../lib/Extension";
import {
requiresTrailingNode,
trailingNodeNotAfter,
} from "../lib/trailingNode";
/**
* Options for the TrailingNode extension.
@@ -20,15 +23,12 @@ export default class TrailingNode extends Extension<TrailingNodeOptions> {
get defaultOptions(): TrailingNodeOptions {
return {
node: "paragraph",
notAfter: ["paragraph", "heading"],
notAfter: trailingNodeNotAfter,
};
}
get plugins() {
const plugin = new PluginKey(this.name);
const disabledNodes = Object.entries(this.editor.schema.nodes)
.map(([, value]) => value)
.filter((node: NodeType) => this.options.notAfter.includes(node.name));
return [
new Plugin({
@@ -49,38 +49,12 @@ export default class TrailingNode extends Extension<TrailingNodeOptions> {
},
}),
state: {
init: (_, state) => {
const lastNode = state.tr.doc.lastChild;
// If paragraph has no text (only images/media), add trailing node
if (
lastNode?.type.name === "paragraph" &&
lastNode.content.size > 0 &&
lastNode.textContent.length === 0
) {
return true;
}
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
},
apply: (tr, value) => {
if (!tr.docChanged) {
return value;
}
const lastNode = tr.doc.lastChild;
// If paragraph has no text (only images/media), add trailing node
if (
lastNode?.type.name === "paragraph" &&
lastNode.content.size > 0 &&
lastNode.textContent.length === 0
) {
return true;
}
return lastNode ? !disabledNodes.includes(lastNode.type) : false;
},
init: (_, state) =>
requiresTrailingNode(state.doc, this.options.notAfter),
apply: (tr, value) =>
tr.docChanged
? requiresTrailingNode(tr.doc, this.options.notAfter)
: value,
},
}),
];
+77
View File
@@ -0,0 +1,77 @@
import { Schema } from "prosemirror-model";
import { requiresTrailingNode, withTrailingNode } from "./trailingNode";
const schema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { group: "block", content: "inline*" },
heading: { group: "block", content: "inline*" },
code_block: { group: "block", content: "inline*" },
image: { group: "inline", inline: true },
text: { group: "inline" },
},
});
const doc = (...children: object[]) =>
schema.nodeFromJSON({ type: "doc", content: children });
const paragraph = (text?: string) => ({
type: "paragraph",
content: text ? [{ type: "text", text }] : [],
});
describe("requiresTrailingNode", () => {
it("is false when the document ends in a paragraph", () => {
expect(requiresTrailingNode(doc(paragraph("hello")))).toBe(false);
});
it("is false when the document ends in a heading", () => {
expect(
requiresTrailingNode(
doc({ type: "heading", content: [{ type: "text", text: "title" }] })
)
).toBe(false);
});
it("is true when the document ends in another block type", () => {
expect(
requiresTrailingNode(
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
)
).toBe(true);
});
it("is true when the last paragraph contains only non-text content", () => {
expect(
requiresTrailingNode(
doc({ type: "paragraph", content: [{ type: "image" }] })
)
).toBe(true);
});
});
describe("withTrailingNode", () => {
it("appends a trailing paragraph when required", () => {
const result = withTrailingNode(
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
);
expect(result.childCount).toBe(2);
expect(result.lastChild?.type.name).toBe("paragraph");
expect(result.lastChild?.content.size).toBe(0);
});
it("is a no-op when a trailing paragraph already exists", () => {
const input = doc(
{ type: "code_block", content: [{ type: "text", text: "x" }] },
paragraph()
);
expect(withTrailingNode(input).eq(input)).toBe(true);
});
it("is idempotent", () => {
const once = withTrailingNode(
doc({ type: "code_block", content: [{ type: "text", text: "x" }] })
);
expect(withTrailingNode(once).eq(once)).toBe(true);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { Fragment, type Node } from "prosemirror-model";
/** Node names after which a trailing paragraph is not required. */
export const trailingNodeNotAfter = ["paragraph", "heading"];
/**
* Determines whether the editor would insert a trailing paragraph after the
* document's last node. Mirrors the behavior of the TrailingNode extension so
* that stored content can be normalized to match the editor, avoiding a
* spurious edit the first time a document is opened.
*
* @param doc The document node to inspect.
* @param notAfter Node names after which a trailing node is not required.
* @returns whether a trailing paragraph is required.
*/
export function requiresTrailingNode(
doc: Node,
notAfter: string[] = trailingNodeNotAfter
): boolean {
const lastNode = doc.lastChild;
if (!lastNode) {
return false;
}
// A paragraph holding only non-text content (eg. images) still needs a
// trailing node so the cursor can be placed after it.
if (
lastNode.type.name === "paragraph" &&
lastNode.content.size > 0 &&
lastNode.textContent.length === 0
) {
return true;
}
return !notAfter.includes(lastNode.type.name);
}
/**
* Appends a trailing paragraph to the document if the editor would add one on
* load, returning the normalized document unchanged otherwise.
*
* @param doc The document node to normalize.
* @param notAfter Node names after which a trailing node is not required.
* @returns the document, with a trailing paragraph appended when required.
*/
export function withTrailingNode(
doc: Node,
notAfter: string[] = trailingNodeNotAfter
): Node {
const paragraph = doc.type.schema.nodes.paragraph;
if (!paragraph || !requiresTrailingNode(doc, notAfter)) {
return doc;
}
return doc.copy(doc.content.append(Fragment.from(paragraph.create())));
}