Skip to content

Commit

Permalink
Added serialization for React custom blocks
Browse files Browse the repository at this point in the history
  • Loading branch information
matthewlipski committed Jun 22, 2023
1 parent 9aa6657 commit bc30dbd
Show file tree
Hide file tree
Showing 3 changed files with 118 additions and 65 deletions.
162 changes: 103 additions & 59 deletions packages/react/src/ReactBlockSpec.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import {
BlockConfig,
BlockNoteEditor,
BlockSchema,
BlockSpec,
blockStyles,
camelToDataKebab,
createTipTapBlock,
getBlockFromPos,
parse,
PropSchema,
propsToAttributes,
render,
SpecificBlock,
} from "@blocknote/core";
import {
NodeViewContent,
Expand All @@ -17,43 +19,85 @@ import {
ReactNodeViewRenderer,
} from "@tiptap/react";
import { FC, HTMLAttributes } from "react";
import { renderToString } from "react-dom/server";

// extend BlockConfig but use a react render function
// `BlockConfig` which returns a React component from its `render` function
// instead of a `dom` and optional `contentDOM` element.
export type ReactBlockConfig<
Type extends string,
BType extends string,
PSchema extends PropSchema,
ContainsInlineContent extends boolean,
BSchema extends BlockSchema
BSchema extends BlockSchema & { [k in BType]: BlockSpec<BType, PSchema> }
> = Omit<
BlockConfig<Type, PSchema, ContainsInlineContent, BSchema>,
BlockConfig<BType, PSchema, ContainsInlineContent, BSchema>,
"render"
> & {
render: FC<{
block: Parameters<
BlockConfig<Type, PSchema, ContainsInlineContent, BSchema>["render"]
BlockConfig<BType, PSchema, ContainsInlineContent, BSchema>["render"]
>[0];
editor: Parameters<
BlockConfig<Type, PSchema, ContainsInlineContent, BSchema>["render"]
BlockConfig<BType, PSchema, ContainsInlineContent, BSchema>["render"]
>[1];
}>;
};

export const InlineContent = (props: HTMLAttributes<HTMLDivElement>) => (
<NodeViewContent
{...props}
className={`${props.className ? props.className + " " : ""}${
blockStyles.inlineContent
}`}
/>
);
// React component that's used instead of declaring a `contentDOM` for React
// custom blocks.
export const InlineContent = (props: HTMLAttributes<HTMLDivElement>) => {
return (
<NodeViewContent
{...props}
className={`${props.className ? props.className + " " : ""}${
blockStyles.inlineContent
}`}
/>
);
};

// 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 reactRenderWithBlockStructure<
BType extends string,
PSchema extends PropSchema,
ContainsInlineContent extends boolean,
BSchema extends BlockSchema & { [k in BType]: BlockSpec<BType, PSchema> }
>(
render: ReactBlockConfig<
BType,
PSchema,
ContainsInlineContent,
BSchema
>["render"],
block: SpecificBlock<BSchema, BType>,
editor: BlockNoteEditor<BSchema>
) {
const Content = render;
// Add props as HTML attributes in kebab-case with "data-" prefix
const htmlAttributes: Record<string, string> = {};
// Add props as HTML attributes in kebab-case with "data-" prefix
for (const [prop, value] of Object.entries(block.props)) {
htmlAttributes[camelToDataKebab(prop)] = value;
}

return () => (
<NodeViewWrapper
className={blockStyles.blockContent}
data-content-type={block.type}
{...htmlAttributes}>
<Content block={block} editor={editor} />
</NodeViewWrapper>
);
}

// A function to create custom block for API consumers
// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
export function createReactBlockSpec<
BType extends string,
PSchema extends PropSchema,
ContainsInlineContent extends boolean,
BSchema extends BlockSchema
BSchema extends BlockSchema & { [k in BType]: BlockSpec<BType, PSchema> }
>(
blockConfig: ReactBlockConfig<BType, PSchema, ContainsInlineContent, BSchema>
): BlockSpec<BType, PSchema> {
Expand All @@ -76,57 +120,57 @@ export function createReactBlockSpec<
return parse(blockConfig);
},

renderHTML({ HTMLAttributes }) {
return render(blockConfig, HTMLAttributes);
},

addNodeView() {
const BlockContent: FC<NodeViewProps> = (props: NodeViewProps) => {
const Content = blockConfig.render;

// Add props as HTML attributes in kebab-case with "data-" prefix
const htmlAttributes: Record<string, string> = {};
for (const [attribute, value] of Object.entries(props.node.attrs)) {
if (attribute in blockConfig.propSchema) {
htmlAttributes[camelToDataKebab(attribute)] = value;
}
}
return (props) =>
ReactNodeViewRenderer(
(props: NodeViewProps) => {
// Gets the BlockNote editor instance
const editor = this.options.editor as BlockNoteEditor<BSchema>;
// Gets the block
const block = getBlockFromPos<BType, PSchema, BSchema>(
props.getPos,
editor,
this.editor,
blockConfig.type
);

// Gets BlockNote editor instance
const editor = this.options.editor!;
// Gets position of the node
const pos =
typeof props.getPos === "function" ? props.getPos() : undefined;
// Gets TipTap editor instance
const tipTapEditor = editor._tiptapEditor;
// Gets parent blockContainer node
const blockContainer = tipTapEditor.state.doc.resolve(pos!).node();
// Gets block identifier
const blockIdentifier = blockContainer.attrs.id;
// Get the block
const block = editor.getBlock(blockIdentifier)!;
if (block.type !== blockConfig.type) {
throw new Error("Block type does not match");
}

return (
<NodeViewWrapper
className={blockStyles.blockContent}
data-content-type={blockConfig.type}
{...htmlAttributes}>
<Content block={block} editor={editor} />
</NodeViewWrapper>
);
};
const BlockContent = reactRenderWithBlockStructure<
BType,
PSchema,
ContainsInlineContent,
BSchema
>(blockConfig.render, block, this.options.editor);

return ReactNodeViewRenderer(BlockContent, {
className: blockStyles.reactNodeViewRenderer,
});
return <BlockContent />;
},
{
className: blockStyles.reactNodeViewRenderer,
}
)(props);
},
});

return {
node: node,
propSchema: blockConfig.propSchema,
serialize: (block, editor) => {
// TODO: This doesn't seem like a great way of doing things - any better
// method for converting a component to HTML?
const blockContentWrapper = document.createElement("div");
const BlockContent = reactRenderWithBlockStructure<
BType,
PSchema,
ContainsInlineContent,
BSchema
>(blockConfig.render, block, editor as any);
blockContentWrapper.innerHTML = renderToString(<BlockContent />);

return {
dom: blockContentWrapper.firstChild! as HTMLElement,
contentDOM: blockContentWrapper.getElementsByClassName(
blockStyles.inlineContent
)[0],
};
},
};
}
9 changes: 8 additions & 1 deletion tests/utils/customblocks/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { BlockSchema, createBlockSpec, defaultProps } from "@blocknote/core";
import {
BlockSchema,
BlockSpec,
createBlockSpec,
defaultProps,
PropSchema,
} from "@blocknote/core";
import { ReactSlashMenuItem } from "@blocknote/react";
import { RiImage2Fill } from "react-icons/ri";

Expand All @@ -16,6 +22,7 @@ export const Image = createBlockSpec({
image.setAttribute("src", block.props.src);
image.setAttribute("contenteditable", "false");
image.setAttribute("style", "width: 100%");
image.setAttribute("alt", "Image");

const caption = document.createElement("div");
caption.setAttribute("style", "flex-grow: 1");
Expand Down
12 changes: 7 additions & 5 deletions tests/utils/customblocks/ReactImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
createReactBlockSpec,
ReactSlashMenuItem,
} from "@blocknote/react";
import { defaultProps } from "@blocknote/core";
import { BlockSchema, defaultProps } from "@blocknote/core";
import { RiImage2Fill } from "react-icons/ri";

export const ReactImage = createReactBlockSpec({
Expand All @@ -30,15 +30,17 @@ export const ReactImage = createReactBlockSpec({
alt={"Image"}
contentEditable={false}
/>
<InlineContent />
<InlineContent style={{ flexGrow: 1 }} />
</div>
);
},
});

export const insertReactImage = new ReactSlashMenuItem<{
reactImage: typeof ReactImage;
}>(
export const insertReactImage = new ReactSlashMenuItem<
BlockSchema & {
reactImage: typeof ReactImage;
}
>(
"Insert React Image",
(editor) => {
const src = prompt("Enter image URL");
Expand Down

0 comments on commit bc30dbd

Please sign in to comment.