diff --git a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.node.test.ts b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.node.test.ts index ffa6b973ab..a225205177 100644 --- a/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.node.test.ts +++ b/packages/core/src/editor/managers/ExtensionManager/ExtensionManager.node.test.ts @@ -1,26 +1,32 @@ -// @vitest-environment node +// @vitest-environment jsdom import { Plugin, PluginKey } from "prosemirror-state"; import { Extension as TiptapExtension } from "@tiptap/core"; import { afterEach, describe, expect, it } from "vite-plus/test"; +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; +import { createInlineContentSpec } from "../../../schema/inlineContent/createSpec.js"; +import { createStyleSpec } from "../../../schema/styles/createSpec.js"; import { createExtension } from "../../BlockNoteExtension.js"; import { BlockNoteEditor } from "../../BlockNoteEditor.js"; -const editors: BlockNoteEditor[] = []; +const editors: Array<{ unmount(): void }> = []; +const hosts: HTMLElement[] = []; afterEach(() => { for (const editor of editors.splice(0)) { - editor._tiptapEditor.destroy(); + editor.unmount(); + } + for (const host of hosts.splice(0)) { + host.remove(); } }); -function rebuildPlugins(editor: BlockNoteEditor) { - // This is the state-only step Tiptap performs on every mount. No view is - // required to prove that its plugin source disagrees with BlockNote's registry. - editor.prosemirrorView.updateState( - editor.prosemirrorState.reconfigure({ - plugins: editor._tiptapEditor.extensionManager.plugins, - }), - ); +function remountEditor(editor: Pick) { + editor.unmount(); + const host = document.createElement("div"); + document.body.append(host); + hosts.push(host); + editor.mount(host); } describe("runtime extension plugin source", () => { @@ -66,7 +72,7 @@ describe("runtime extension plugin source", () => { ], }); editors.push(editor); - rebuildPlugins(editor); + remountEditor(editor); editor.replaceBlocks(editor.document, [ { type: "paragraph", content: "Keep content" }, ]); @@ -78,7 +84,7 @@ describe("runtime extension plugin source", () => { expect(key.getState(editor.prosemirrorState)).toBe(2); expect(retainedKey.getState(editor.prosemirrorState)).toBe(99); expect(editor.prosemirrorState.doc.textContent).toBe("Keep content"); - rebuildPlugins(editor); + remountEditor(editor); expect(key.getState(editor.prosemirrorState)).toBe(2); }); it("does not reinstall a removed extension", () => { @@ -89,10 +95,10 @@ describe("runtime extension plugin source", () => { })); const editor = BlockNoteEditor.create({ extensions: [extension()] }); editors.push(editor); - rebuildPlugins(editor); + remountEditor(editor); expect(key.get(editor.prosemirrorState)).toBeDefined(); editor.unregisterExtension(extension); - rebuildPlugins(editor); + remountEditor(editor); expect(key.get(editor.prosemirrorState)).toBeUndefined(); }); @@ -107,13 +113,13 @@ describe("runtime extension plugin source", () => { }); const editor = BlockNoteEditor.create(); editors.push(editor); - rebuildPlugins(editor); + remountEditor(editor); editor.registerExtension({ key: "added", prosemirrorPlugins: [plugin] }); editor.replaceBlocks(editor.document, [ { type: "paragraph", content: "Changed" }, ]); expect(key.getState(editor.prosemirrorState)).toBe(1); - rebuildPlugins(editor); + remountEditor(editor); expect(key.get(editor.prosemirrorState)).toBe(plugin); expect(key.getState(editor.prosemirrorState)).toBe(1); }); @@ -130,12 +136,12 @@ describe("runtime extension plugin source", () => { }; const editor = BlockNoteEditor.create({ extensions: [() => original] }); editors.push(editor); - rebuildPlugins(editor); + remountEditor(editor); editor.replaceExtension(original, replacement); expect(editor.prosemirrorState.plugins.at(-1)).toBe( replacement.prosemirrorPlugins[0], ); - rebuildPlugins(editor); + remountEditor(editor); expect(key.get(editor.prosemirrorState)).toBe( replacement.prosemirrorPlugins[0], ); @@ -162,10 +168,10 @@ describe("runtime extension plugin source", () => { ], }); editors.push(editor); - rebuildPlugins(editor); + remountEditor(editor); editor.registerExtension({ key: "runtime", prosemirrorPlugins: [added] }); expect(editor.prosemirrorState.plugins.at(-1)).toBe(added); - rebuildPlugins(editor); + remountEditor(editor); expect(editor.prosemirrorState.plugins.at(-1)).toBe(added); }); @@ -207,3 +213,118 @@ describe("runtime extension plugin source", () => { expect(remove(editor, ["second"])).toEqual([second]); }); }); + +it("registers extensions and plugins from editor options, nested extensions, and schema specs", () => { + function render() { + const dom = document.createElement("span"); + return { dom, contentDOM: dom }; + } + + function registration(key: string) { + const plugin = new Plugin({ key: new PluginKey(key) }); + const nestedPlugin = new Plugin({ key: new PluginKey(`${key}-nested`) }); + const extension = createExtension(() => ({ + key, + prosemirrorPlugins: [plugin], + tiptapExtensions: [ + TiptapExtension.create({ + name: `${key}-tiptap`, + addExtensions: () => [ + TiptapExtension.create({ + name: `${key}-nested`, + addProseMirrorPlugins: () => [nestedPlugin], + }), + ], + }), + ], + })); + return { extension, plugin, nestedPlugin }; + } + + const options = registration("options"); + const nested = registration("nested"); + const block = registration("block"); + const inline = registration("inline"); + const style = registration("style"); + const styleChild = registration("style-child"); + const blockSpec = createBlockSpec( + { type: "customBlock", propSchema: {}, content: "inline" }, + { render }, + )(); + const inlineSpec = createInlineContentSpec( + { type: "customInline", propSchema: {}, content: "styled" }, + { render }, + ); + const styleSpec = createStyleSpec( + { type: "customStyle", propSchema: "boolean" }, + { render }, + [ + style.extension(), + createExtension({ + key: "style-parent", + blockNoteExtensions: [styleChild.extension()], + }), + ], + ); + const inlinePlugin = new Plugin({ key: new PluginKey("inline-node") }); + const stylePlugin = new Plugin({ key: new PluginKey("style-mark") }); + const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + customBlock: { ...blockSpec, extensions: [block.extension()] }, + }, + inlineContentSpecs: { + customInline: { + ...inlineSpec, + extensions: [inline.extension()], + implementation: { + ...inlineSpec.implementation, + node: inlineSpec.implementation.node.extend({ + addProseMirrorPlugins: () => [inlinePlugin], + }), + }, + }, + }, + styleSpecs: { + customStyle: { + ...styleSpec, + implementation: { + render, + mark: styleSpec.implementation.mark.extend({ + addProseMirrorPlugins: () => [stylePlugin], + }), + }, + }, + }, + }); + const editor = BlockNoteEditor.create({ + schema, + extensions: [ + options.extension(), + createExtension({ + key: "parent", + blockNoteExtensions: [nested.extension()], + }), + ], + }); + editors.push(editor); + remountEditor(editor); + const plugins = editor.prosemirrorState.plugins; + + expect(editor.pmSchema.nodes.customBlock).toBeDefined(); + expect(editor.pmSchema.nodes.customInline).toBeDefined(); + expect(editor.pmSchema.marks.customStyle).toBeDefined(); + for (const { extension, plugin, nestedPlugin } of [ + options, + nested, + block, + inline, + style, + styleChild, + ]) { + expect(editor.getExtension(extension)).toBeDefined(); + expect(plugins).toContain(plugin); + expect(plugins).toContain(nestedPlugin); + } + expect(plugins).toContain(inlinePlugin); + expect(plugins).toContain(stylePlugin); +}); diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 34e7ad4e76..76a8674cc4 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -105,10 +105,16 @@ export class ExtensionManager { this.addExtension(extension); } - // Add the extensions from blocks specs - for (const block of Object.values(this.editor.schema.blockSpecs)) { - for (const extension of block.extensions ?? []) { - this.addExtension(extension); + // Add the extensions from block, inline content, and style specs + for (const specs of [ + this.editor.schema.blockSpecs, + this.editor.schema.inlineContentSpecs, + this.editor.schema.styleSpecs, + ]) { + for (const spec of Object.values(specs)) { + for (const extension of spec.extensions ?? []) { + this.addExtension(extension); + } } } } diff --git a/packages/core/src/schema/inlineContent/types.ts b/packages/core/src/schema/inlineContent/types.ts index bcc670fe88..7523471fcc 100644 --- a/packages/core/src/schema/inlineContent/types.ts +++ b/packages/core/src/schema/inlineContent/types.ts @@ -94,8 +94,8 @@ export type InlineContentSpec = { export type InlineContentSchema = Record; export type InlineContentSpecs = { - text: { config: "text"; implementation: undefined }; - link: { config: "link"; implementation: undefined }; + text: InlineContentSpec<"text"> & { implementation: undefined }; + link: InlineContentSpec<"link"> & { implementation: undefined }; } & Record>; export type InlineContentSchemaFromSpecs = { diff --git a/packages/core/src/schema/styles/createSpec.ts b/packages/core/src/schema/styles/createSpec.ts index c8700da0f2..265b037aad 100644 --- a/packages/core/src/schema/styles/createSpec.ts +++ b/packages/core/src/schema/styles/createSpec.ts @@ -1,4 +1,8 @@ import { Mark } from "@tiptap/core"; +import type { + Extension, + ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; import { ParseRule, TagParseRule } from "@tiptap/pm/model"; import { @@ -72,6 +76,7 @@ export function getStyleParseRules( export function createStyleSpec( styleConfig: T, styleImplementation: CustomStyleImplementation, + extensions?: (Extension | ExtensionFactoryInstance)[], ): StyleSpec { const mark = Mark.create({ name: styleConfig.type, @@ -111,30 +116,34 @@ export function createStyleSpec( }, }); - return createInternalStyleSpec(styleConfig, { - ...styleImplementation, - mark, - render: (value) => { - const renderResult = styleImplementation.render(value as any); + return createInternalStyleSpec( + styleConfig, + { + ...styleImplementation, + mark, + render: (value) => { + const renderResult = styleImplementation.render(value as any); - return addStyleAttributes( - renderResult, - styleConfig.type, - value, - styleConfig.propSchema, - ); - }, - toExternalHTML: (value) => { - const renderResult = ( - styleImplementation.toExternalHTML || styleImplementation.render - )(value as any); + return addStyleAttributes( + renderResult, + styleConfig.type, + value, + styleConfig.propSchema, + ); + }, + toExternalHTML: (value) => { + const renderResult = ( + styleImplementation.toExternalHTML || styleImplementation.render + )(value as any); - return addStyleAttributes( - renderResult, - styleConfig.type, - value, - styleConfig.propSchema, - ); + return addStyleAttributes( + renderResult, + styleConfig.type, + value, + styleConfig.propSchema, + ); + }, }, - }); + extensions, + ); } diff --git a/packages/core/src/schema/styles/internal.ts b/packages/core/src/schema/styles/internal.ts index 9d5711bd6e..e47ec1c39b 100644 --- a/packages/core/src/schema/styles/internal.ts +++ b/packages/core/src/schema/styles/internal.ts @@ -1,5 +1,9 @@ import { Attributes, Mark } from "@tiptap/core"; import { DOMSerializer } from "@tiptap/pm/model"; +import type { + Extension, + ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; import { StyleConfig, StyleImplementation, @@ -68,10 +72,12 @@ export function addStyleAttributes< export function createInternalStyleSpec( config: T, implementation: StyleImplementation, + extensions?: (Extension | ExtensionFactoryInstance)[], ) { return { config, implementation, + extensions, } satisfies StyleSpec; } diff --git a/packages/core/src/schema/styles/types.ts b/packages/core/src/schema/styles/types.ts index a0eeb7e416..f709093e1f 100644 --- a/packages/core/src/schema/styles/types.ts +++ b/packages/core/src/schema/styles/types.ts @@ -1,5 +1,9 @@ import { Mark } from "@tiptap/core"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import type { + Extension, + ExtensionFactoryInstance, +} from "../../editor/BlockNoteExtension.js"; export type StylePropSchema = "boolean" | "string"; // TODO: use PropSchema as name? Use objects as type similar to blocks? @@ -36,6 +40,7 @@ export type StyleImplementation = { export type StyleSpec = { config: T; implementation: StyleImplementation; + extensions?: (Extension | ExtensionFactoryInstance)[]; }; // A Schema contains all the types (Configs) supported in an editor diff --git a/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx b/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx new file mode 100644 index 0000000000..63ea726f60 --- /dev/null +++ b/packages/react/src/schema/ReactInlineContentSpec.browser.test.tsx @@ -0,0 +1,102 @@ +import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { expect, it } from "vite-plus/test"; + +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { createReactInlineContentSpec } from "./ReactInlineContentSpec.js"; + +function renderSource({ + contentRef, +}: { + contentRef: (node: HTMLElement | null) => void; +}) { + return ( + + Preview + + + ); +} + +it.each([ + ["plain", "insertion"], + ["styled", "insertion"], + ["plain", "undo"], + ["styled", "undo"], +] as const)( + "mounts %s inline content inside its contentRef after %s", + async (content, action) => { + const schema = BlockNoteSchema.create().extend({ + inlineContentSpecs: { + plain: createReactInlineContentSpec( + { type: "plain", propSchema: {}, content: "plain" }, + { render: renderSource }, + ), + styled: createReactInlineContentSpec( + { type: "styled", propSchema: {}, content: "styled" }, + { render: renderSource }, + ), + }, + }); + const editor = BlockNoteEditor.create({ + schema, + trailingBlock: false, + initialContent: [ + { + id: "paragraph", + type: "paragraph", + content: + action === "undo" + ? [{ type: content, props: {}, content: "x^2" }] + : [], + }, + ], + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + async function expectContentMounted() { + await expect + .poll( + () => + host.querySelector( + `[data-inline-content-type="${content}"] .source`, + )?.textContent, + ) + .toBe("x^2"); + const inline = host.querySelector( + `[data-inline-content-type="${content}"]`, + ); + const source = inline?.querySelector(".source"); + expect(source?.textContent).toBe("x^2"); + // Checking the actual parent, not the marker attribute, catches content + // appended beside the preview when TipTap creates contentDOM after React. + expect( + inline?.querySelector("[data-node-view-content-react]")?.parentElement, + ).toBe(source); + expect(inline?.textContent).toBe("Previewx^2"); + } + + try { + flushSync(() => root.render()); + if (action === "insertion") { + editor.setTextCursorPosition("paragraph", "start"); + editor.insertInlineContent([ + { type: content, props: {}, content: "x^2" }, + ]); + } else { + await expectContentMounted(); + editor.removeBlocks(["paragraph"]); + expect(host.querySelector(".source")).toBeNull(); + expect(editor.undo()).toBe(true); + } + await expectContentMounted(); + } finally { + root.unmount(); + editor.unmount(); + host.remove(); + } + }, +); diff --git a/packages/react/src/schema/ReactInlineContentSpec.tsx b/packages/react/src/schema/ReactInlineContentSpec.tsx index 863d5421a3..a705ab67ca 100644 --- a/packages/react/src/schema/ReactInlineContentSpec.tsx +++ b/packages/react/src/schema/ReactInlineContentSpec.tsx @@ -227,6 +227,11 @@ export function createReactInlineContentSpec< ref(element); if (element) { element.dataset.editable = ""; + // Inline content inserted after the editor has mounted + // renders before TipTap creates its contentDOM, so `ref` + // can't mount it yet. TipTap then looks for this + // attribute to know where to mount it instead. + element.dataset.nodeViewContent = ""; } }} editor={editor} diff --git a/packages/react/src/schema/ReactStyleSpec.tsx b/packages/react/src/schema/ReactStyleSpec.tsx index 1b6825e0b5..b780b2c9a9 100644 --- a/packages/react/src/schema/ReactStyleSpec.tsx +++ b/packages/react/src/schema/ReactStyleSpec.tsx @@ -1,5 +1,7 @@ import { BlockNoteEditor, + type Extension, + type ExtensionFactoryInstance, StyleConfig, addStyleAttributes, createInternalStyleSpec, @@ -35,11 +37,13 @@ export type ReactCustomStyleImplementation = { * `propSchema` (`"boolean"` or `"string"`). * @param styleImplementation - The React implementation, including a `render` * component that receives the style value, a `contentRef`, and the editor. + * @param extensions - BlockNote extensions registered with this style. * @returns A style spec that can be passed to the editor's schema. */ export function createReactStyleSpec( styleConfig: T, styleImplementation: ReactCustomStyleImplementation, + extensions?: (Extension | ExtensionFactoryInstance)[], ) { const mark = Mark.create({ name: styleConfig.type, @@ -125,58 +129,62 @@ export function createReactStyleSpec( }, }); - return createInternalStyleSpec(styleConfig, { - ...styleImplementation, - mark, - render(value, editor) { - const Content = styleImplementation.render; - const output = renderToDOMSpec( - (ref) => ( - { - ref(element); - if (element) { - element.dataset.editable = ""; - } - }} - /> - ), - editor, - ); + return createInternalStyleSpec( + styleConfig, + { + ...styleImplementation, + mark, + render(value, editor) { + const Content = styleImplementation.render; + const output = renderToDOMSpec( + (ref) => ( + { + ref(element); + if (element) { + element.dataset.editable = ""; + } + }} + /> + ), + editor, + ); - return addStyleAttributes( - output, - styleConfig.type, - value, - styleConfig.propSchema, - ); - }, - toExternalHTML(value, editor) { - const Content = styleImplementation.render; - const output = renderToDOMSpec( - (ref) => ( - { - ref(element); - if (element) { - element.dataset.editable = ""; - } - }} - /> - ), - editor, - ); + return addStyleAttributes( + output, + styleConfig.type, + value, + styleConfig.propSchema, + ); + }, + toExternalHTML(value, editor) { + const Content = styleImplementation.render; + const output = renderToDOMSpec( + (ref) => ( + { + ref(element); + if (element) { + element.dataset.editable = ""; + } + }} + /> + ), + editor, + ); - return addStyleAttributes( - output, - styleConfig.type, - value, - styleConfig.propSchema, - ); + return addStyleAttributes( + output, + styleConfig.type, + value, + styleConfig.propSchema, + ); + }, }, - }); + extensions, + ); } diff --git a/tests/src/unit/core/schema/__snapshots__/styles.json b/tests/src/unit/core/schema/__snapshots__/styles.json index f18eb02114..258208471c 100644 --- a/tests/src/unit/core/schema/__snapshots__/styles.json +++ b/tests/src/unit/core/schema/__snapshots__/styles.json @@ -4,6 +4,7 @@ "propSchema": "string", "type": "backgroundColor", }, + "extensions": undefined, "implementation": { "mark": null, "parse": [Function], @@ -16,6 +17,7 @@ "propSchema": "boolean", "type": "bold", }, + "extensions": undefined, "implementation": { "mark": null, "render": [Function], @@ -27,6 +29,7 @@ "propSchema": "boolean", "type": "code", }, + "extensions": undefined, "implementation": { "mark": null, "render": [Function], @@ -38,6 +41,7 @@ "propSchema": "string", "type": "fontSize", }, + "extensions": undefined, "implementation": { "mark": null, "render": [Function], @@ -49,6 +53,7 @@ "propSchema": "boolean", "type": "italic", }, + "extensions": undefined, "implementation": { "mark": null, "render": [Function], @@ -60,6 +65,7 @@ "propSchema": "boolean", "type": "small", }, + "extensions": undefined, "implementation": { "mark": null, "render": [Function], @@ -71,6 +77,7 @@ "propSchema": "boolean", "type": "strike", }, + "extensions": undefined, "implementation": { "mark": null, "render": [Function], @@ -82,6 +89,7 @@ "propSchema": "string", "type": "textColor", }, + "extensions": undefined, "implementation": { "mark": null, "parse": [Function], @@ -94,6 +102,7 @@ "propSchema": "boolean", "type": "underline", }, + "extensions": undefined, "implementation": { "mark": null, "render": [Function],