@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
422 lines (375 loc) • 15.1 kB
text/typescript
import { describe, expect, it } from "vite-plus/test";
import * as Y from "yjs";
import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js";
import { CommentsExtension } from "../../comments/extension.js";
import { DefaultThreadStoreAuth } from "../../comments/threadstore/DefaultThreadStoreAuth.js";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { createBlockConfig, createBlockSpec } from "../../schema/index.js";
import { YAttributionMarksExtension } from "../../y/extensions/YAttributionMarks.js";
import { YjsThreadStore } from "../comments/YjsThreadStore.js";
import { withCollaboration } from "../index.js";
/**
* @vitest-environment jsdom
*/
// These tests cover the mark-dropping part of `FixUpSchemaExtension`, which lets
// a document survive a block whose content type changed from `"inline"` (marks allowed)
// to `"plain"` (only the `"annotation"` group allowed). The key behaviour: the
// disallowed marks REMAIN in the Yjs fragment (we never mutate the Yjs doc), but
// they are NOT materialized into ProseMirror — so the block is not deleted and
// its text is preserved. Everything is exercised end-to-end through a real
// collaborative editor, i.e. through y-prosemirror's actual reconstruction path
// (`schema.node` -> `createChecked`) that would otherwise delete the block.
// A "legacy" schema where the code block holds inline content — i.e. the schema
// from BEFORE the migration to "plain", where a code block could carry
// formatting marks. Used to produce fixtures via a real editor so that marks
// are encoded in Yjs exactly as y-prosemirror encodes them (rather than by
// hand, which could assume the wrong attribute names).
const legacySchema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
codeBlock: createBlockSpec(
createBlockConfig(() => ({
type: "codeBlock" as const,
propSchema: { language: { default: "" } },
content: "inline" as const,
})),
{
render: () => {
const dom = document.createElement("pre");
const code = document.createElement("code");
dom.append(code);
return { dom, contentDOM: code };
},
},
)(),
},
});
const createThreadStore = (ydoc: Y.Doc) =>
new YjsThreadStore(
"test-user",
ydoc.getMap("threads"),
new DefaultThreadStoreAuth("test-user", "editor"),
);
// Mounts a real collaborative editor on the legacy schema (code block = inline,
// so it allows marks), seeded with a bold-formatted code block. Returns the
// editor (typed loosely, since it doesn't use the default schema) so callers can
// apply extra marks before destroying it. Marks therefore land in Yjs exactly
// the way y-prosemirror writes them.
const createLegacyEditor = (
fragment: Y.XmlFragment,
opts: { threadStore?: YjsThreadStore } = {},
) => {
const editor = BlockNoteEditor.create(
withCollaboration({
collaboration: {
fragment,
user: { color: "#0000ff", name: "Legacy User" },
provider: undefined,
},
schema: legacySchema,
// Register the Yjs attribution marks — they're the annotation mark
// reachable from core (the AI suggestion marks live in xl-ai), so they
// stand in for the `"annotation"` group here and let the plain code block
// keep them while dropping formatting.
extensions: [
YAttributionMarksExtension(),
...(opts.threadStore
? [
CommentsExtension({
threadStore: opts.threadStore,
resolveUsers: async () => [],
}),
]
: []),
],
}) as Parameters<typeof BlockNoteEditor.create>[0],
) as unknown as BlockNoteEditor<any, any, any>;
editor.mount(document.createElement("div"));
editor.replaceBlocks(editor.document, [
{
type: "codeBlock",
props: { language: "javascript" },
content: [{ type: "text", text: "const x = 1;", styles: { bold: true } }],
},
]);
return editor;
};
// Populates `fragment` with a bold-formatted code block (optionally also a
// suggestion / `y-attributed-insert` mark).
const buildLegacyFormattedCodeBlock = (
fragment: Y.XmlFragment,
opts: { withSuggestion?: boolean } = {},
) => {
const editor = createLegacyEditor(fragment);
if (opts.withSuggestion) {
editor.transact((tr) =>
tr.addMark(
0,
tr.doc.content.size,
editor.pmSchema.marks["y-attributed-insert"].create({
userIds: ["test-user"],
}),
),
);
}
editor._tiptapEditor.destroy();
};
// Populates `ydoc` with a code block carrying a bold mark AND a real comment
// (created through the comment API, so the comment mark is encoded by
// y-prosemirror as `comment--<hash>`).
const buildLegacyCommentedCodeBlock = async (ydoc: Y.Doc) => {
const editor = createLegacyEditor(ydoc.getXmlFragment("doc"), {
threadStore: createThreadStore(ydoc),
});
(editor as any)._tiptapEditor.commands.selectAll();
await (editor.getExtension(CommentsExtension) as any).createThread({
initialComment: { body: "review this" },
});
editor._tiptapEditor.destroy();
};
const createCollabEditor = (
fragment: Y.XmlFragment,
opts: { threadStore?: YjsThreadStore } = {},
) => {
const editor = BlockNoteEditor.create(
withCollaboration({
collaboration: {
fragment,
user: { color: "#ff0000", name: "Test User" },
provider: undefined,
},
// Register the same annotation mark on the reconstructing editor, so it's
// allowed on the plain block and materialized rather than dropped.
extensions: [
YAttributionMarksExtension(),
...(opts.threadStore
? [
CommentsExtension({
threadStore: opts.threadStore,
resolveUsers: async () => [],
}),
]
: []),
],
}),
);
editor.mount(document.createElement("div"));
return editor;
};
// Collects every mark-attribute key present on a fragment's text.
const markKeysIn = (fragment: Y.XmlFragment) => {
const keys = new Set<string>();
const walk = (node: Y.AbstractType<any>) => {
if (node instanceof Y.XmlText) {
for (const op of node.toDelta() as { attributes?: object }[]) {
if (op.attributes) {
Object.keys(op.attributes).forEach((k) => keys.add(k));
}
}
} else if (node instanceof Y.XmlElement || node instanceof Y.XmlFragment) {
node.toArray().forEach(walk);
}
};
fragment.toArray().forEach(walk);
return keys;
};
// Collects the style keys present on a block's inline content (empty for plain
// text). A materialized formatting mark would show up here.
const styleKeysOf = (block: any) => {
const keys = new Set<string>();
if (Array.isArray(block.content)) {
for (const inline of block.content) {
if (inline.styles) {
Object.keys(inline.styles).forEach((k) => keys.add(k));
}
}
}
return keys;
};
describe("FixUpSchema: formatted code block backwards compatibility", () => {
it("preserves a formatted code block present before mount, dropping only the formatting", () => {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("doc");
buildLegacyFormattedCodeBlock(fragment);
// The fixture really does carry a bold mark, encoded by y-prosemirror.
expect(markKeysIn(fragment).has("bold")).toBe(true);
const editor = createCollabEditor(fragment);
// The block survives reconstruction with its text intact...
expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]);
expect(editor.document[0].content).toEqual([
{
styles: {},
text: "const x = 1;",
type: "text",
},
]);
// ...bold is NOT materialized into ProseMirror...
expect(styleKeysOf(editor.document[0]).has("bold")).toBe(false);
// ...but the mark is STILL in the Yjs fragment (we never mutate the Yjs doc).
expect(markKeysIn(fragment).has("bold")).toBe(true);
editor._tiptapEditor.destroy();
});
it("preserves a formatted code block that syncs in after mount", () => {
const remoteDoc = new Y.Doc();
buildLegacyFormattedCodeBlock(remoteDoc.getXmlFragment("doc"));
const localDoc = new Y.Doc();
const localFragment = localDoc.getXmlFragment("doc");
// Editor mounts on an empty fragment, before any content arrives.
const editor = createCollabEditor(localFragment);
expect(localFragment.toJSON()).toBe("");
// Provider delivers the initial state after mount.
Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(remoteDoc));
// The code block survives in the editor...
expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]);
expect(editor.document[0].content).toEqual([
{
styles: {},
text: "const x = 1;",
type: "text",
},
]);
// ...and in the Yjs fragment — i.e. it was NOT deleted (which would
// otherwise propagate the deletion back to every peer)...
expect(localFragment.toJSON()).toContain("const x = 1;");
// ...with its formatting not materialized into ProseMirror...
expect(styleKeysOf(editor.document[0]).has("bold")).toBe(false);
// ...but the bold mark still present in the fragment (left in Yjs).
expect(markKeysIn(localFragment).has("bold")).toBe(true);
editor._tiptapEditor.destroy();
});
it("keeps the reconstructed block editable (leftover marks don't break positions)", () => {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("doc");
buildLegacyFormattedCodeBlock(fragment);
const editor = createCollabEditor(fragment);
const block = editor.document[0];
expect(block.content).toEqual([
{
styles: {},
text: "const x = 1;",
type: "text",
},
]);
// Editing the reconstructed block applies cleanly despite the leftover bold
// mark still living in the Yjs fragment.
editor.updateBlock(block, { content: "const x = 2;" });
expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]);
expect(editor.document[0].content).toEqual([
{
styles: {},
text: "const x = 2;",
type: "text",
},
]);
// Still no materialized formatting after the edit.
expect(styleKeysOf(editor.document[0]).has("bold")).toBe(false);
editor._tiptapEditor.destroy();
});
it("keeps suggestion (y-attributed-insert) marks while dropping formatting", () => {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("doc");
buildLegacyFormattedCodeBlock(fragment, { withSuggestion: true });
const before = markKeysIn(fragment);
expect(before.has("bold")).toBe(true);
// y-prosemirror encodes a mark that carries attributes with a hash suffix
// (e.g. `y-attributed-insert--<hash>`).
expect([...before].some((k) => k.startsWith("y-attributed-insert"))).toBe(
true,
);
const editor = createCollabEditor(fragment);
// Block survives with text intact.
expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]);
expect(editor.document[0].content).toEqual([
{
styles: {},
text: "const x = 1;",
type: "text",
},
]);
const after = markKeysIn(fragment);
// Formatting left in Yjs but not materialized; suggestion (allowed) kept.
expect(after.has("bold")).toBe(true);
expect([...after].some((k) => k.startsWith("y-attributed-insert"))).toBe(
true,
);
expect(styleKeysOf(editor.document[0]).has("bold")).toBe(false);
editor._tiptapEditor.destroy();
});
it("keeps a real comment mark (encoded as comment--<hash>) while dropping formatting", async () => {
const ydoc = new Y.Doc();
await buildLegacyCommentedCodeBlock(ydoc);
const fragment = ydoc.getXmlFragment("doc");
// The comment mark is stored under a hashed key, not just "comment".
const before = markKeysIn(fragment);
expect(before.has("bold")).toBe(true);
expect([...before].some((k) => k.startsWith("comment--"))).toBe(true);
// Mount a comments-configured collab editor on the same doc, so the comment
// mark exists in the schema and is allowed on the (plain) code block.
const editor = createCollabEditor(fragment, {
threadStore: createThreadStore(ydoc),
});
// Block survives with text intact.
expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]);
expect(editor.document[0].content).toEqual([
{
styles: {},
text: "const x = 1;",
type: "text",
},
]);
const after = markKeysIn(fragment);
// The hashed comment mark is kept (allowed); bold left in Yjs but not
// materialized as formatting.
expect([...after].some((k) => k.startsWith("comment--"))).toBe(true);
expect(after.has("bold")).toBe(true);
expect(styleKeysOf(editor.document[0]).has("bold")).toBe(false);
editor._tiptapEditor.destroy();
});
it("leaves an already-clean code block untouched (no-op for valid content)", () => {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("doc");
// A plain code block produced by the current schema (no disallowed marks).
const seedEditor = createCollabEditor(fragment);
seedEditor.replaceBlocks(seedEditor.document, [
{ type: "codeBlock", content: "const x = 1;" },
]);
const before = fragment.toJSON();
seedEditor._tiptapEditor.destroy();
// Reconstruct it in a fresh collab editor: the override is a no-op, so the
// fragment is byte-for-byte unchanged.
const editor = createCollabEditor(fragment);
expect(editor.document.map((b) => b.type)).toEqual(["codeBlock"]);
expect(editor.document[0].content).toEqual([
{
styles: {},
text: "const x = 1;",
type: "text",
},
]);
expect(fragment.toJSON()).toBe(before);
editor._tiptapEditor.destroy();
});
it("does not touch inline blocks: a bold paragraph keeps its formatting", () => {
const doc = new Y.Doc();
const fragment = doc.getXmlFragment("doc");
// A paragraph is "inline" content, so bold is allowed and should survive
// reconstruction untouched — the override is scoped to plain blocks.
const seedEditor = createCollabEditor(fragment);
seedEditor.replaceBlocks(seedEditor.document, [
{
type: "paragraph",
content: [{ type: "text", text: "hello", styles: { bold: true } }],
},
]);
const before = fragment.toJSON();
seedEditor._tiptapEditor.destroy();
const editor = createCollabEditor(fragment);
expect(editor.document.map((b) => b.type)).toEqual(["paragraph"]);
// Bold is materialized (kept) on the inline block.
expect(styleKeysOf(editor.document[0]).has("bold")).toBe(true);
// And the fragment is unchanged (override was a no-op for this node).
expect(fragment.toJSON()).toBe(before);
editor._tiptapEditor.destroy();
});
});