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:
@@ -0,0 +1,79 @@
|
|||||||
|
import { Node } from "prosemirror-model";
|
||||||
|
import { prosemirrorToYDoc } from "y-prosemirror";
|
||||||
|
import { schema } from "@server/editor";
|
||||||
|
import { buildDocument, buildUser } from "@server/test/factories";
|
||||||
|
import documentCollaborativeUpdater from "./documentCollaborativeUpdater";
|
||||||
|
|
||||||
|
describe("documentCollaborativeUpdater", () => {
|
||||||
|
const buildYDoc = (content: object[]) => {
|
||||||
|
const doc = Node.fromJSON(schema, { type: "doc", content });
|
||||||
|
return prosemirrorToYDoc(doc, "default");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("persists canonical JSON without empty attrs on marks", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const document = await buildDocument({
|
||||||
|
teamId: user.teamId,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ydoc = buildYDoc([
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Deciders:",
|
||||||
|
marks: [{ type: "strong" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await documentCollaborativeUpdater({
|
||||||
|
documentId: document.id,
|
||||||
|
ydoc,
|
||||||
|
sessionCollaboratorIds: [user.id],
|
||||||
|
isLastConnection: true,
|
||||||
|
clientVersion: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await document.reload();
|
||||||
|
|
||||||
|
const marks = JSON.stringify(document.content).match(/"attrs":\{\}/g);
|
||||||
|
expect(marks).toBeNull();
|
||||||
|
|
||||||
|
const text = document.content?.content?.[0]?.content?.[0];
|
||||||
|
expect(text?.marks).toEqual([{ type: "strong" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist when content is unchanged", async () => {
|
||||||
|
const user = await buildUser();
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "Hello" }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const ydoc = buildYDoc(content);
|
||||||
|
|
||||||
|
const document = await buildDocument({
|
||||||
|
teamId: user.teamId,
|
||||||
|
userId: user.id,
|
||||||
|
content: Node.fromJSON(schema, { type: "doc", content }).toJSON(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedAt = document.updatedAt;
|
||||||
|
|
||||||
|
await documentCollaborativeUpdater({
|
||||||
|
documentId: document.id,
|
||||||
|
ydoc,
|
||||||
|
sessionCollaboratorIds: [user.id],
|
||||||
|
isLastConnection: true,
|
||||||
|
clientVersion: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await document.reload();
|
||||||
|
expect(document.updatedAt).toEqual(updatedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import isEqual from "fast-deep-equal";
|
import isEqual from "fast-deep-equal";
|
||||||
import { uniq } from "es-toolkit/compat";
|
import { uniq } from "es-toolkit/compat";
|
||||||
|
import { Node } from "prosemirror-model";
|
||||||
import { yDocToProsemirrorJSON } from "y-prosemirror";
|
import { yDocToProsemirrorJSON } from "y-prosemirror";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import type { ProsemirrorData } from "@shared/types";
|
import type { ProsemirrorData } from "@shared/types";
|
||||||
|
import { schema } from "@server/editor";
|
||||||
import Logger from "@server/logging/Logger";
|
import Logger from "@server/logging/Logger";
|
||||||
import { Document, Event } from "@server/models";
|
import { Document, Event } from "@server/models";
|
||||||
import { sequelize } from "@server/storage/database";
|
import { sequelize } from "@server/storage/database";
|
||||||
@@ -50,7 +52,14 @@ export default async function documentCollaborativeUpdater({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const state = Y.encodeStateAsUpdate(ydoc);
|
const state = Y.encodeStateAsUpdate(ydoc);
|
||||||
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
|
|
||||||
|
// Round-trip through the schema so the stored JSON is canonical. The raw
|
||||||
|
// y-prosemirror output includes empty `attrs: {}` on every mark, and outputs
|
||||||
|
// properties in a different order - resulting in spurious "edits"
|
||||||
|
const content = Node.fromJSON(
|
||||||
|
schema,
|
||||||
|
yDocToProsemirrorJSON(ydoc, "default")
|
||||||
|
).toJSON() as ProsemirrorData;
|
||||||
const isUnchanged = isEqual(document.content, content);
|
const isUnchanged = isEqual(document.content, content);
|
||||||
const isDeleted = !!document.deletedAt;
|
const isDeleted = !!document.deletedAt;
|
||||||
const lastModifiedById = isDeleted
|
const lastModifiedById = isDeleted
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import Diff from "@shared/editor/extensions/Diff";
|
|||||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||||
import type { ExtendedChange } from "@shared/editor/lib/ChangesetHelper";
|
import type { ExtendedChange } from "@shared/editor/lib/ChangesetHelper";
|
||||||
import textBetween from "@shared/editor/lib/textBetween";
|
import textBetween from "@shared/editor/lib/textBetween";
|
||||||
|
import { withTrailingNode } from "@shared/editor/lib/trailingNode";
|
||||||
import EditorContainer from "@shared/editor/components/Styles";
|
import EditorContainer from "@shared/editor/components/Styles";
|
||||||
import GlobalStyles from "@shared/styles/globals";
|
import GlobalStyles from "@shared/styles/globals";
|
||||||
import light from "@shared/styles/theme";
|
import light from "@shared/styles/theme";
|
||||||
@@ -106,15 +107,16 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
|
|||||||
* @returns The content as a Y.Doc.
|
* @returns The content as a Y.Doc.
|
||||||
*/
|
*/
|
||||||
static toYDoc(input: string | ProsemirrorData, fieldName = "default"): Y.Doc {
|
static toYDoc(input: string | ProsemirrorData, fieldName = "default"): Y.Doc {
|
||||||
if (typeof input === "object") {
|
const node =
|
||||||
return prosemirrorToYDoc(
|
typeof input === "object"
|
||||||
ProsemirrorHelper.toProsemirror(input),
|
? ProsemirrorHelper.toProsemirror(input)
|
||||||
fieldName
|
: parser.parse(input);
|
||||||
);
|
if (!node) {
|
||||||
|
return new Y.Doc();
|
||||||
}
|
}
|
||||||
|
// Normalize to the editor's trailing-node form so the document opens without
|
||||||
const node = parser.parse(input);
|
// the editor inserting a trailing paragraph, which would be a spurious edit.
|
||||||
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
|
return prosemirrorToYDoc(withTrailingNode(node), fieldName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { NodeType } from "prosemirror-model";
|
|
||||||
import { Plugin, PluginKey } from "prosemirror-state";
|
import { Plugin, PluginKey } from "prosemirror-state";
|
||||||
import Extension from "../lib/Extension";
|
import Extension from "../lib/Extension";
|
||||||
|
import {
|
||||||
|
requiresTrailingNode,
|
||||||
|
trailingNodeNotAfter,
|
||||||
|
} from "../lib/trailingNode";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for the TrailingNode extension.
|
* Options for the TrailingNode extension.
|
||||||
@@ -20,15 +23,12 @@ export default class TrailingNode extends Extension<TrailingNodeOptions> {
|
|||||||
get defaultOptions(): TrailingNodeOptions {
|
get defaultOptions(): TrailingNodeOptions {
|
||||||
return {
|
return {
|
||||||
node: "paragraph",
|
node: "paragraph",
|
||||||
notAfter: ["paragraph", "heading"],
|
notAfter: trailingNodeNotAfter,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get plugins() {
|
get plugins() {
|
||||||
const plugin = new PluginKey(this.name);
|
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 [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
@@ -49,38 +49,12 @@ export default class TrailingNode extends Extension<TrailingNodeOptions> {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
state: {
|
state: {
|
||||||
init: (_, state) => {
|
init: (_, state) =>
|
||||||
const lastNode = state.tr.doc.lastChild;
|
requiresTrailingNode(state.doc, this.options.notAfter),
|
||||||
|
apply: (tr, value) =>
|
||||||
// If paragraph has no text (only images/media), add trailing node
|
tr.docChanged
|
||||||
if (
|
? requiresTrailingNode(tr.doc, this.options.notAfter)
|
||||||
lastNode?.type.name === "paragraph" &&
|
: value,
|
||||||
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;
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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