Skip to content
Open
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
21 changes: 16 additions & 5 deletions packages/core/src/schema/blocks/createSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,12 @@ export function addNodeAndExtensionsToSpec<
this.options.domAttributes?.blockContent || {};

const nodeView = blockImplementation.render.call(
{ blockContentDOMAttributes, props, renderType: "nodeView" },
{
blockContentDOMAttributes,
props,
renderType: "nodeView",
propSchema: blockConfig.propSchema,
},
block as any,
editor as any,
);
Expand Down Expand Up @@ -248,6 +253,7 @@ export function addNodeAndExtensionsToSpec<
blockContentDOMAttributes,
props: undefined,
renderType: "dom",
propSchema: blockConfig.propSchema,
},
block as any,
editor as any,
Expand All @@ -261,13 +267,18 @@ export function addNodeAndExtensionsToSpec<

return (
blockImplementation.toExternalHTML?.call(
{ blockContentDOMAttributes },
{ blockContentDOMAttributes, propSchema: blockConfig.propSchema },
block as any,
editor as any,
context,
) ??
blockImplementation.render.call(
{ blockContentDOMAttributes, renderType: "dom", props: undefined },
{
blockContentDOMAttributes,
renderType: "dom",
props: undefined,
propSchema: blockConfig.propSchema,
},
block as any,
editor as any,
)
Expand Down Expand Up @@ -404,7 +415,7 @@ export function createBlockSpec<
output,
block.type,
block.props,
blockConfig.propSchema,
this.propSchema ?? blockConfig.propSchema,
blockImplementation.meta?.fileBlockAccept !== undefined,
);
},
Expand All @@ -423,7 +434,7 @@ export function createBlockSpec<
output,
block.type,
block.props,
blockConfig.propSchema,
this.propSchema ?? blockConfig.propSchema,
blockImplementation.meta?.fileBlockAccept !== undefined,
this.blockContentDOMAttributes,
) satisfies NodeView;
Expand Down
188 changes: 188 additions & 0 deletions packages/core/src/schema/blocks/extendBlockSpec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, it } from "vite-plus/test";
import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js";
import { createParagraphBlockSpec } from "../../blocks/Paragraph/block.js";
import { createHeadingBlockSpec } from "../../blocks/Heading/block.js";

/**
* @vitest-environment jsdom
*/

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

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

function withMetadata<
T extends { config: { propSchema: Record<string, unknown> } },
>(spec: T): T {
return {
...spec,
config: {
...spec.config,
propSchema: {
...spec.config.propSchema,
metadata: { default: "" },
},
},
} as T;
}

function createExtendedSchema() {
return BlockNoteSchema.create({
blockSpecs: Object.fromEntries(
Object.entries(defaultBlockSpecs).map(([key, spec]) => [
key,
withMetadata(spec),
]),
) as typeof defaultBlockSpecs,
});
}

function createExtendedEditor() {
const editor = BlockNoteEditor.create({ schema: createExtendedSchema() });
editorsToCleanup.push(editor);
return editor;
}

describe("extending default block specs with additional props", () => {
it("creates an editor with an extended paragraph prop without crashing", () => {
const paragraph = createParagraphBlockSpec();
const schema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
paragraph: {
...paragraph,
config: {
...paragraph.config,
propSchema: {
...paragraph.config.propSchema,
metadata: { default: "" },
},
},
},
},
});

const editor = BlockNoteEditor.create({ schema });
editorsToCleanup.push(editor);

expect(editor.document[0].type).toBe("paragraph");
expect((editor.document[0].props as any).metadata).toBe("");
});

it("creates an editor using a helper that extends all block specs", () => {
const editor = createExtendedEditor();

expect(editor.document[0].type).toBe("paragraph");
expect((editor.document[0].props as any).metadata).toBe("");
});

it("can update the extended prop on a block", () => {
const editor = createExtendedEditor();

editor.updateBlock(editor.document[0], {
props: { metadata: '{"source":"xwiki"}' } as any,
});

expect((editor.document[0].props as any).metadata).toBe(
'{"source":"xwiki"}',
);
});

it("renders the extended prop in full HTML export", () => {
const editor = createExtendedEditor();

editor.updateBlock(editor.document[0], {
props: { metadata: "test-value" } as any,
content: "Hello",
});

const html = editor.blocksToFullHTML(editor.document);
expect(html).toContain('data-metadata="test-value"');
});

it("does not render the extended prop when it equals the default", () => {
const editor = createExtendedEditor();

editor.updateBlock(editor.document[0], {
content: "Hello",
});

const html = editor.blocksToFullHTML(editor.document);
expect(html).not.toContain("data-metadata");
});

it("works when extending only specific block types", () => {
const paragraph = createParagraphBlockSpec();
const heading = createHeadingBlockSpec();

const schema = BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
paragraph: withMetadata(paragraph),
heading: withMetadata(heading),
},
});

const editor = BlockNoteEditor.create({ schema });
editorsToCleanup.push(editor);

editor.replaceBlocks(editor.document, [
{
type: "paragraph",
content: "Text",
props: { metadata: "p-meta" } as any,
},
{
type: "heading",
content: "Title",
props: { metadata: "h-meta", level: 1 } as any,
},
{ type: "bulletListItem", content: "Item" },
]);

expect((editor.document[0].props as any).metadata).toBe("p-meta");
expect((editor.document[1].props as any).metadata).toBe("h-meta");
expect(editor.document[2].props).not.toHaveProperty("metadata");

const html = editor.blocksToFullHTML(editor.document);
expect(html).toContain('data-metadata="p-meta"');
expect(html).toContain('data-metadata="h-meta"');
});

it("round-trips extended props through full HTML export and parse", () => {
const editor = createExtendedEditor();

editor.updateBlock(editor.document[0], {
props: { metadata: '{"key":"value"}' } as any,
content: "Round trip test",
});

const html = editor.blocksToFullHTML(editor.document);
const parsed = editor.tryParseHTMLToBlocks(html);

expect((parsed[0].props as any).metadata).toBe('{"key":"value"}');
});

it("mounts the editor to a DOM element without crashing", () => {
const editor = createExtendedEditor();

const container = document.createElement("div");
editor.mount(container);

expect(container.innerHTML).toContain("bn-block-content");

editor.updateBlock(editor.document[0], {
props: { metadata: "mounted-test" } as any,
content: "Mounted",
});

expect((editor.document[0].props as any).metadata).toBe("mounted-test");
});
});
3 changes: 1 addition & 2 deletions packages/core/src/schema/blocks/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,7 @@ export function wrapInBlockStructure<
// element (inheritedProps) and props set to their default values.
for (const [prop, value] of Object.entries(blockProps)) {
const spec = propSchema[prop];
const defaultValue = spec.default;
if (value !== defaultValue) {
if (value !== spec?.default) {
blockContent.setAttribute(camelToDataKebab(prop), value);
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/schema/blocks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ export type BlockImplementation<
| Record<string, never>
| ({
blockContentDOMAttributes: Record<string, string>;
propSchema?: TProps;
} & (
| {
renderType: "nodeView";
Expand Down Expand Up @@ -520,6 +521,7 @@ export type BlockImplementation<
toExternalHTML?: (
this: Partial<{
blockContentDOMAttributes: Record<string, string>;
propSchema: TProps;
}>,
block: BlockFromConfig<BlockConfig<TName, TProps, TContent>, any, any>,
editor: BlockNoteEditor<
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/inlineContent/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function addInlineContentAttributes<
Object.entries(inlineContentProps)
.filter(([prop, value]) => {
const spec = propSchema[prop];
return value !== spec.default;
return value !== spec?.default;
})
.map(([prop, value]) => {
return [camelToDataKebab(prop), value];
Expand Down
Loading