Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions packages/core/src/schema/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, describe, expect, it } from "vite-plus/test";

import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js";
import {
defaultBlockSpecs,
defaultInlineContentSpecs,
defaultStyleSpecs,
} from "../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
import { createExtension } from "../editor/BlockNoteExtension.js";
import { createBlockSpec } from "./blocks/createSpec.js";
import { createInlineContentSpec } from "./inlineContent/createSpec.js";
import { createStyleSpec } from "./styles/createSpec.js";

const editorsToCleanup: BlockNoteEditor<any, any, any>[] = [];

afterEach(() => {
for (const editor of editorsToCleanup) {
editor.unmount();
}
editorsToCleanup.length = 0;
});

function createMountedEditor(
options: Parameters<typeof BlockNoteEditor.create>[0],
) {
const editor = BlockNoteEditor.create(options);
editor.mount(document.createElement("div"));
editorsToCleanup.push(editor);
return editor;
}

// A block spec that registers an editor extension, like
// `@blocknote/xl-multi-column`'s `ColumnBlock` does with its drop handler.
const CustomBlockExtension = createExtension(() => ({
key: "customBlockExtension",
}));

const createCustomBlockSpec = createBlockSpec(
{ type: "customBlock", propSchema: {}, content: "none" },
{ render: () => ({ dom: document.createElement("div") }) },
[CustomBlockExtension()],
);

const customInlineContent = createInlineContentSpec(
{ type: "customInlineContent", propSchema: {}, content: "none" },
{ render: () => ({ dom: document.createElement("span") }) },
);

const customStyle = createStyleSpec(
{ type: "customStyle", propSchema: "boolean" },
{ render: () => ({ dom: document.createElement("span") }) },
);

describe("CustomBlockNoteSchema.extend", () => {
it("does not mutate the default specs shared by every default schema", () => {
const extended = BlockNoteSchema.create().extend({
blockSpecs: { customBlock: createCustomBlockSpec() },
inlineContentSpecs: { customInlineContent },
styleSpecs: { customStyle },
});

// The extended schema has the added specs...
expect(extended.blockSchema).toHaveProperty("customBlock");
expect(extended.inlineContentSchema).toHaveProperty("customInlineContent");
expect(extended.styleSchema).toHaveProperty("customStyle");

// ...the module-level defaults it was created from are untouched...
expect(defaultBlockSpecs).not.toHaveProperty("customBlock");
expect(defaultInlineContentSpecs).not.toHaveProperty("customInlineContent");
expect(defaultStyleSpecs).not.toHaveProperty("customStyle");

// ...so a default schema created afterwards doesn't inherit them.
const fresh = BlockNoteSchema.create();
expect(fresh.blockSchema).not.toHaveProperty("customBlock");
expect(fresh.inlineContentSchema).not.toHaveProperty("customInlineContent");
expect(fresh.styleSchema).not.toHaveProperty("customStyle");
});

it("does not mutate the spec objects passed to `create`", () => {
const blockSpecs = { paragraph: defaultBlockSpecs.paragraph };

BlockNoteSchema.create({ blockSpecs }).extend({
blockSpecs: { customBlock: createCustomBlockSpec() },
});

expect(Object.keys(blockSpecs)).toEqual(["paragraph"]);
});

it("keeps an extended schema's blocks and their extensions out of later default-schema editors", () => {
// Regression: extending a default schema used to write the added block
// specs into the shared default specs, so every editor created afterwards
// without an explicit schema silently got the extra block types - and the
// editor extensions they register (e.g. multi-column's drop handler).
const extendedEditor = createMountedEditor({
schema: BlockNoteSchema.create().extend({
blockSpecs: { customBlock: createCustomBlockSpec() },
}),
});
expect(extendedEditor.getExtension(CustomBlockExtension)).toBeDefined();

const defaultEditor = createMountedEditor({});
expect(defaultEditor.schema.blockSchema).not.toHaveProperty("customBlock");
expect(defaultEditor.pmSchema.nodes).not.toHaveProperty("customBlock");
expect(defaultEditor.getExtension(CustomBlockExtension)).toBeUndefined();
});
});
17 changes: 13 additions & 4 deletions packages/core/src/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,19 @@ export class CustomBlockNoteSchema<
[K in keyof AdditionalStyleSpecs]: AdditionalStyleSpecs[K]["config"];
}
> {
// Merge the new specs with existing ones
Object.assign(this.opts.blockSpecs, opts.blockSpecs);
Object.assign(this.opts.inlineContentSpecs, opts.inlineContentSpecs);
Object.assign(this.opts.styleSpecs, opts.styleSpecs);
// Merge the new specs with the existing ones into fresh objects. The
// existing spec objects must not be written to: `BlockNoteSchema.create()`
// passes the module-level default specs by reference, so mutating them
// would leak the added specs (and the editor extensions they register) into
// every default-schema editor created afterwards.
this.opts = {
blockSpecs: { ...this.opts.blockSpecs, ...opts.blockSpecs },
inlineContentSpecs: {
...this.opts.inlineContentSpecs,
...opts.inlineContentSpecs,
},
styleSpecs: { ...this.opts.styleSpecs, ...opts.styleSpecs },
};

// Reinitialize the block specs with the merged specs
const {
Expand Down
Loading