@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
257 lines (209 loc) • 8.87 kB
text/typescript
import { Schema } from "prosemirror-model";
import { describe, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
import { docToBlocks } from "./nodeConversions/nodeToBlock.js";
import { getNodeId } from "./getBlockInfoFromPos.js";
import { YAttributionMarksExtension } from "../y/extensions/YAttributionMarks.js";
/**
* Builds a `blockContainer` node holding a single paragraph with the given
* block `id`. When `suggestedDelete` is true, the container carries a
* `y-attributed-delete` mark, simulating a node that Yjs keeps in the document
* (in suggestion mode) after it has been deleted.
*/
function makeBlockContainer(
schema: Schema,
id: string,
text: string,
suggestedDelete: boolean,
) {
const paragraph = schema.nodes["paragraph"].createChecked(
{},
text ? schema.text(text) : null,
);
const marks = suggestedDelete
? [schema.marks["y-attributed-delete"].create({ id: 1 })]
: undefined;
return schema.nodes["blockContainer"].createChecked({ id }, paragraph, marks);
}
describe("getNodeId", () => {
let editor: BlockNoteEditor;
// We only need the editor's ProseMirror schema to construct nodes, so a
// single non-mounted editor instance is enough for all cases here.
function getSchema() {
if (!editor) {
editor = BlockNoteEditor.create({
extensions: [YAttributionMarksExtension()],
});
}
return editor.pmSchema;
}
it("returns the plain id for a normal block", () => {
const schema = getSchema();
const block = makeBlockContainer(schema, "0", "Hello", false);
const doc = schema.nodes["doc"].createChecked(
{},
schema.nodes["blockGroup"].createChecked({}, block),
);
// The only descendant blockContainer with id "0" is the one we built.
const blockContainer = doc.firstChild!.firstChild!;
expect(getNodeId(blockContainer, doc)).toBe("0");
});
it("throws when a node has no id", () => {
const schema = getSchema();
// `create` (not `createChecked`) so we can omit the id attr default lying.
const block = schema.nodes["blockContainer"].create(
{ id: null },
schema.nodes["paragraph"].createChecked({}, schema.text("No id")),
);
expect(() => getNodeId(block, block)).toThrow(/does not have an ID/);
});
it("lies about the id of a suggested-deletion block to disambiguate duplicates", () => {
const schema = getSchema();
// First block: a "real" block with id "0".
const liveBlock = makeBlockContainer(schema, "0", "Live", false);
// Second block: a suggested deletion that, in suggestion mode, shares the
// SAME id "0" as the live block but carries a y-attributed-delete mark.
const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true);
const doc = schema.nodes["doc"].createChecked(
{},
schema.nodes["blockGroup"].createChecked({}, [liveBlock, deletedBlock]),
);
const blockGroup = doc.firstChild!;
const liveNode = blockGroup.child(0);
const deletedNode = blockGroup.child(1);
// The live block keeps its plain id.
expect(getNodeId(liveNode, doc)).toBe("0");
// The suggested-deletion block is disambiguated: it is preceded by one
// node with the same id, so its index is 1 -> "0-1".
expect(getNodeId(deletedNode, doc)).toBe("0-1");
});
it("disambiguates multiple suggested-deletion blocks with the same id", () => {
const schema = getSchema();
// Three blocks all sharing id "0": one live block followed by two
// suggested deletions (e.g. the user deleted the same logical block twice
// across forks, all kept in the doc with the y-attributed-delete mark).
const liveBlock = makeBlockContainer(schema, "0", "Live", false);
const deletedBlock1 = makeBlockContainer(schema, "0", "Deleted 1", true);
const deletedBlock2 = makeBlockContainer(schema, "0", "Deleted 2", true);
const doc = schema.nodes["doc"].createChecked(
{},
schema.nodes["blockGroup"].createChecked({}, [
liveBlock,
deletedBlock1,
deletedBlock2,
]),
);
const blockGroup = doc.firstChild!;
expect(getNodeId(blockGroup.child(0), doc)).toBe("0");
// Preceded by 1 node with the same id.
expect(getNodeId(blockGroup.child(1), doc)).toBe("0-1");
// Preceded by 2 nodes with the same id.
expect(getNodeId(blockGroup.child(2), doc)).toBe("0-2");
});
it("counts only preceding same-id nodes, not unrelated blocks", () => {
const schema = getSchema();
// A block with a different id sits between the live and deleted blocks.
// It must NOT contribute to the suggested-deletion block's index.
const liveBlock = makeBlockContainer(schema, "0", "Live", false);
const otherBlock = makeBlockContainer(schema, "1", "Other", false);
const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true);
const doc = schema.nodes["doc"].createChecked(
{},
schema.nodes["blockGroup"].createChecked({}, [
liveBlock,
otherBlock,
deletedBlock,
]),
);
const blockGroup = doc.firstChild!;
expect(getNodeId(blockGroup.child(0), doc)).toBe("0");
expect(getNodeId(blockGroup.child(1), doc)).toBe("1");
// Only the single live block with id "0" precedes it -> index 1.
expect(getNodeId(blockGroup.child(2), doc)).toBe("0-1");
});
it("throws when a suggested-deletion node is not found in the provided doc", () => {
const schema = getSchema();
// A suggested-deletion block that is NOT part of `doc` -> the walk never
// finds it, so getNodeId throws.
const orphanDeleted = makeBlockContainer(schema, "0", "Orphan", true);
const doc = schema.nodes["doc"].createChecked(
{},
schema.nodes["blockGroup"].createChecked(
{},
makeBlockContainer(schema, "0", "Live", false),
),
);
expect(() => getNodeId(orphanDeleted, doc)).toThrow(
/not found in document/,
);
});
});
describe("docToBlocks round trip with suggested deletions", () => {
let editor: BlockNoteEditor;
function getSchema() {
if (!editor) {
editor = BlockNoteEditor.create({
extensions: [YAttributionMarksExtension()],
});
}
return editor.pmSchema;
}
it("reports distinct block ids even though two ProseMirror nodes share the same id", () => {
const schema = getSchema();
// A live block and a suggested-deletion block that, in suggestion mode,
// share the SAME ProseMirror id "0".
const liveBlock = makeBlockContainer(schema, "0", "Live", false);
const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true);
const doc = schema.nodes["doc"].createChecked(
{},
schema.nodes["blockGroup"].createChecked({}, [liveBlock, deletedBlock]),
);
// At the ProseMirror level, both nodes share id "0".
const blockGroup = doc.firstChild!;
expect(blockGroup.child(0).attrs.id).toBe("0");
expect(blockGroup.child(1).attrs.id).toBe("0");
// docToBlocks disambiguates them via getNodeId: the live block keeps "0",
// the suggested-deletion block becomes "0-1".
const blocks = docToBlocks(doc);
const ids = blocks.map((block) => block.id);
expect(ids).toEqual(["0", "0-1"]);
// All block ids are distinct.
expect(new Set(ids).size).toBe(ids.length);
});
it("disambiguates multiple suggested-deletion blocks sharing an id in docToBlocks", () => {
const schema = getSchema();
const liveBlock = makeBlockContainer(schema, "0", "Live", false);
const deletedBlock1 = makeBlockContainer(schema, "0", "Deleted 1", true);
const deletedBlock2 = makeBlockContainer(schema, "0", "Deleted 2", true);
const doc = schema.nodes["doc"].createChecked(
{},
schema.nodes["blockGroup"].createChecked({}, [
liveBlock,
deletedBlock1,
deletedBlock2,
]),
);
const blocks = docToBlocks(doc);
const ids = blocks.map((block) => block.id);
expect(ids).toEqual(["0", "0-1", "0-2"]);
expect(new Set(ids).size).toBe(ids.length);
});
it("only disambiguates the suggested-deletion block, leaving unrelated ids intact", () => {
const schema = getSchema();
const liveBlock = makeBlockContainer(schema, "0", "Live", false);
const otherBlock = makeBlockContainer(schema, "1", "Other", false);
const deletedBlock = makeBlockContainer(schema, "0", "Deleted", true);
const doc = schema.nodes["doc"].createChecked(
{},
schema.nodes["blockGroup"].createChecked({}, [
liveBlock,
otherBlock,
deletedBlock,
]),
);
const blocks = docToBlocks(doc);
const ids = blocks.map((block) => block.id);
expect(ids).toEqual(["0", "1", "0-1"]);
expect(new Set(ids).size).toBe(ids.length);
});
});