mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
fix: Spurious post-import edits (#12620)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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())));
|
||||
}
|
||||
Reference in New Issue
Block a user