@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
89 lines (81 loc) • 4.49 kB
text/typescript
import { Attrs, Fragment, Mark, Node, NodeType } from "prosemirror-model";
import { isPlainContentNodeType } from "../../api/pmUtil.js";
import { createExtension } from "../../editor/BlockNoteExtension.js";
// Collaboration-only patches to the live ProseMirror schema. Both are applied
// once on `create`, before y-prosemirror binds and reconstructs the document.
export const FixUpSchemaExtension = createExtension(({ editor }) => {
editor.on("create", () => {
const schema = editor.pmSchema;
// 1. Preserve the initial block id.
// When y-prosemirror creates an empty document, the `blockContainer` node is created with an `id` of `null`.
// This causes the unique id extension to generate a new id for the initial block, which is not what we want
// Since it will be randomly generated & cause there to be more updates to the ydoc
// This is a hack to make it so that anytime `schema.doc.createAndFill` is called, the initial block id is already set to "initialBlockId"
let cache: Node | undefined = undefined;
// eslint-disable-next-line @typescript-eslint/unbound-method -- intentionally saving reference for monkey-patching
const oldCreateAndFill = schema.nodes.doc.createAndFill;
schema.nodes.doc.createAndFill = ((...args: any) => {
if (cache) {
return cache;
}
const ret = oldCreateAndFill.apply(schema.nodes.doc, args)!;
// create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state)
const jsonNode = JSON.parse(JSON.stringify(ret.toJSON()));
jsonNode.content[0].content[0].attrs.id = "initialBlockId";
cache = Node.fromJSON(schema, jsonNode);
return cache;
}) as unknown as typeof schema.nodes.doc.createAndFill;
// 2. Drop marks a plain block no longer allows, instead of deleting the block.
// When a block's content type changes across BlockNote versions — e.g. a
// `codeBlock` that was `"inline"` (formatting marks like bold allowed) and is
// now `"plain"` (only the `"annotation"` mark group: comments + suggestions) —
// old Yjs documents carry text with marks the new node type disallows.
//
// y-prosemirror rebuilds each block by calling `schema.node(nodeName, attrs,
// children)`, where `children` are text nodes that may still carry those
// disallowed marks. `createChecked` then throws, and y-prosemirror's error
// handler DELETES the whole block from the Yjs doc — a deletion that propagates
// to every peer (data loss).
//
// Rather than mutating the Yjs doc to strip those marks, we leave them in place
// (marks are just format attributes; they don't affect position counting, so
// editing text is unaffected) and simply never materialize the disallowed ones
// into ProseMirror. We do that by wrapping `schema.node` to filter each text
// child's marks through the parent node type's `allowedMarks` before the node
// is built. The wrapper is scoped to plain-content blocks (the only place this
// migration applies); all other node types pass through untouched.
//
// NOTE: this covers marks that EXIST in the schema but aren't allowed on the
// node. A mark whose type is entirely absent from the schema still hits
// y-prosemirror's earlier `schema.mark` throw (and delete) path — that's a
// different migration scenario and is out of scope here.
// eslint-disable-next-line @typescript-eslint/unbound-method -- intentionally saving reference for monkey-patching
const originalNode = schema.node;
schema.node = function (
this: typeof schema,
type: string | NodeType,
attrs?: Attrs | null,
content?: Fragment | Node | readonly Node[],
marks?: readonly Mark[],
) {
const nodeType = typeof type === "string" ? this.nodes[type] : type;
// y-prosemirror always passes `content` as a plain array of nodes; only
// touch text children that carry marks.
if (
nodeType &&
isPlainContentNodeType(this, nodeType) &&
Array.isArray(content)
) {
content = (content as readonly Node[]).map((child) =>
child?.isText && child.marks.length
? child.mark(nodeType.allowedMarks(child.marks))
: child,
);
}
return originalNode.call(this, type, attrs, content, marks);
} as typeof schema.node;
});
return {
key: "fixUpSchema",
} as const;
});