import { BlockConfig, BlockConfigOrCreator, BlockImplementation, BlockNoDefaults, BlockNoteEditor, BlockSpec, camelToDataKebab, CustomBlockImplementation, Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, getBlockFromPos, mergeCSSClasses, Props, PropSchema, } from "@blocknote/core"; import { NodeViewProps, NodeViewWrapper, ReactNodeViewRenderer, useReactNodeView, } from "@tiptap/react"; import { FC, ReactNode } from "react"; import { renderToDOMSpec } from "./@util/ReactRenderUtil.js"; // this file is mostly analogoues to `customBlocks.ts`, but for React blocks export type ReactCustomBlockRenderProps< B extends BlockConfigOrCreator, Config extends ExtractBlockConfigFromConfigOrCreator = ExtractBlockConfigFromConfigOrCreator, > = { block: BlockNoDefaults, any, any>; editor: BlockNoteEditor, any, any>; } & (Config["content"] extends "inline" ? { contentRef: (node: HTMLElement | null) => void; } : object); // extend BlockConfig but use a React render function export type ReactCustomBlockImplementation< B extends BlockConfigOrCreator = BlockConfigOrCreator, Config extends ExtractBlockConfigFromConfigOrCreator = ExtractBlockConfigFromConfigOrCreator, > = Omit< CustomBlockImplementation< Config["type"], Config["propSchema"], Config["content"] >, "render" | "toExternalHTML" > & { render: FC>; toExternalHTML?: FC< ReactCustomBlockRenderProps & { context: { nestingLevel: number; }; } >; }; export type ReactCustomBlockSpec< B extends BlockConfig = BlockConfig< string, PropSchema, "inline" | "none" >, > = { config: B; implementation: ReactCustomBlockImplementation; extensions?: Extension[]; }; // Function that wraps the React component returned from 'blockConfig.render' in // a `NodeViewWrapper` which also acts as a `blockContent` div. It contains the // block type and props as HTML attributes. export function BlockContentWrapper< BType extends string, PSchema extends PropSchema, >(props: { blockType: BType; blockProps: Props; propSchema: PSchema; isFileBlock?: boolean; domAttributes?: Record; children: ReactNode; }) { return ( // Creates `blockContent` element event.preventDefault()} // Adds custom HTML attributes {...Object.fromEntries( Object.entries(props.domAttributes || {}).filter( ([key]) => key !== "class", ), )} // Sets blockContent class className={mergeCSSClasses( "bn-block-content", props.domAttributes?.class || "", )} // Sets content type attribute data-content-type={props.blockType} // Adds props as HTML attributes in kebab-case with "data-" prefix. Skips // props which are already added as HTML attributes to the parent // `blockContent` element (inheritedProps) and props set to their default // values {...Object.fromEntries( Object.entries(props.blockProps) .filter(([prop, value]) => { const spec = props.propSchema[prop]; return value !== spec.default; }) .map(([prop, value]) => { return [camelToDataKebab(prop), value]; }), )} data-file-block={props.isFileBlock === true || undefined} > {props.children} ); } /** * Helper function to create a React block definition. * Can accept either functions that return the required objects, or the objects directly. */ export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, const TContent extends "inline" | "none", const TOptions extends Record | undefined = undefined, >( blockConfigOrCreator: BlockConfig, blockImplementationOrCreator: | ReactCustomBlockImplementation> | (TOptions extends undefined ? () => ReactCustomBlockImplementation< BlockConfig > : ( options: Partial, ) => ReactCustomBlockImplementation< BlockConfig >), extensionsOrCreator?: | (ExtensionFactoryInstance | Extension)[] | (TOptions extends undefined ? () => (ExtensionFactoryInstance | Extension)[] : ( options: Partial, ) => (ExtensionFactoryInstance | Extension)[]), ): (options?: Partial) => BlockSpec; export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, const TContent extends "inline" | "none", const BlockConf extends BlockConfig, const TOptions extends Partial>, >( blockCreator: (options: Partial) => BlockConf, blockImplementationOrCreator: | ReactCustomBlockImplementation | (TOptions extends undefined ? () => ReactCustomBlockImplementation : ( options: Partial, ) => ReactCustomBlockImplementation), extensionsOrCreator?: | (ExtensionFactoryInstance | Extension)[] | (TOptions extends undefined ? () => (ExtensionFactoryInstance | Extension)[] : ( options: Partial, ) => (ExtensionFactoryInstance | Extension)[]), ): ( options?: Partial, ) => BlockSpec< BlockConf["type"], BlockConf["propSchema"], BlockConf["content"] >; export function createReactBlockSpec< const TName extends string, const TProps extends PropSchema, const TContent extends "inline" | "none", const TOptions extends Record | undefined = undefined, >( blockConfigOrCreator: BlockConfigOrCreator, blockImplementationOrCreator: | ReactCustomBlockImplementation> | (TOptions extends undefined ? () => ReactCustomBlockImplementation< BlockConfig > : ( options: Partial, ) => ReactCustomBlockImplementation< BlockConfig >), extensionsOrCreator?: | (ExtensionFactoryInstance | Extension)[] | (TOptions extends undefined ? () => (ExtensionFactoryInstance | Extension)[] : ( options: Partial, ) => (ExtensionFactoryInstance | Extension)[]), ): (options?: Partial) => BlockSpec { return (options = {} as TOptions) => { const blockConfig = typeof blockConfigOrCreator === "function" ? blockConfigOrCreator(options as any) : blockConfigOrCreator; const blockImplementation = typeof blockImplementationOrCreator === "function" ? blockImplementationOrCreator(options as any) : blockImplementationOrCreator; const extensions = extensionsOrCreator ? typeof extensionsOrCreator === "function" ? extensionsOrCreator(options as any) : extensionsOrCreator : undefined; return { config: blockConfig, implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { const BlockContent = blockImplementation.toExternalHTML || blockImplementation.render; const output = renderToDOMSpec((refCB) => { return ( { refCB(element); if (element) { element.className = mergeCSSClasses( "bn-inline-content", element.className, ); } }} context={context} /> ); }, editor); return output; }, render(block, editor) { if (this.renderType === "nodeView") { return ReactNodeViewRenderer( (props: NodeViewProps) => { // Vanilla JS node views are recreated on each update. However, // using `ReactNodeViewRenderer` makes it so the node view is // only created once, so the block we get in the node view will // be outdated. Therefore, we have to get the block in the // `ReactNodeViewRenderer` instead. const block = getBlockFromPos( props.getPos, editor, props.editor, blockConfig.type, ); const ref = useReactNodeView().nodeViewContentRef; if (!ref) { throw new Error("nodeViewContentRef is not set"); } const BlockContent = blockImplementation.render; return ( { ref(element); if (element) { element.className = mergeCSSClasses( "bn-inline-content", element.className, ); element.dataset.nodeViewContent = ""; } }} /> ); }, { className: "bn-react-node-view-renderer", }, )(this.props!) as ReturnType; } else { const BlockContent = blockImplementation.render; const output = renderToDOMSpec((refCB) => { return ( { refCB(element); if (element) { element.className = mergeCSSClasses( "bn-inline-content", element.className, ); } }} /> ); }, editor); return output; } }, }, extensions: extensions, }; }; }