diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 6df3e68aa4..0fbb33e051 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -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, ); @@ -248,6 +253,7 @@ export function addNodeAndExtensionsToSpec< blockContentDOMAttributes, props: undefined, renderType: "dom", + propSchema: blockConfig.propSchema, }, block as any, editor as any, @@ -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, ) @@ -404,7 +415,7 @@ export function createBlockSpec< output, block.type, block.props, - blockConfig.propSchema, + this.propSchema ?? blockConfig.propSchema, blockImplementation.meta?.fileBlockAccept !== undefined, ); }, @@ -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; diff --git a/packages/core/src/schema/blocks/extendBlockSpec.test.ts b/packages/core/src/schema/blocks/extendBlockSpec.test.ts new file mode 100644 index 0000000000..7c77de0161 --- /dev/null +++ b/packages/core/src/schema/blocks/extendBlockSpec.test.ts @@ -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[] = []; + +afterEach(() => { + for (const editor of editorsToCleanup) { + editor.unmount(); + } + editorsToCleanup.length = 0; +}); + +function withMetadata< + T extends { config: { propSchema: Record } }, +>(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"); + }); +}); diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index eed8cf9fa3..512fb88b08 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -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); } } diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 97550ee331..c738871c50 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -486,6 +486,7 @@ export type BlockImplementation< | Record | ({ blockContentDOMAttributes: Record; + propSchema?: TProps; } & ( | { renderType: "nodeView"; @@ -520,6 +521,7 @@ export type BlockImplementation< toExternalHTML?: ( this: Partial<{ blockContentDOMAttributes: Record; + propSchema: TProps; }>, block: BlockFromConfig, any, any>, editor: BlockNoteEditor< diff --git a/packages/core/src/schema/inlineContent/internal.ts b/packages/core/src/schema/inlineContent/internal.ts index 9d10c7cb4e..799c41e4f1 100644 --- a/packages/core/src/schema/inlineContent/internal.ts +++ b/packages/core/src/schema/inlineContent/internal.ts @@ -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];