Skip to content
Merged
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
16 changes: 8 additions & 8 deletions docs/content/docs/features/custom-schemas/container-blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const createPanel = createReactBlockSpec(
container: true,
},
{
render: (props) => (
<div className="panel" ref={props.contentRef} />
renderFrame: (props) => (
<div className="panel" ref={props.slotRef} />
),
},
);
Expand All @@ -43,13 +43,13 @@ The block config defines the content and child blocks your container can hold:

### Block Implementation

`render:` Your React component defines how the block should look. For a container, attach `contentRef` where the child blocks should appear. With `content: "inline"` or `"plain"`, attach it to the block's own editable text. You can add icons, buttons, or other elements around it:
`renderFrame:` Your React component draws the container's box. A container has no content of its own, so it has no `render`, only this frame. Attach `slotRef` where the child blocks should appear. You can add icons, buttons, or other elements around it:

```tsx
render: (props) => (
renderFrame: (props) => (
<div className="panel">
<span contentEditable={false}>💡</span>
<div ref={props.contentRef} />
<div ref={props.slotRef} />
</div>
),
```
Expand Down Expand Up @@ -131,7 +131,7 @@ export const createCallout = createReactBlockSpec(
),
renderFrame: (props) => (
<div className="callout">
<div ref={props.contentRef} />
<div ref={props.slotRef} />
</div>
),
},
Expand All @@ -148,11 +148,11 @@ render: (props) => <pre ref={props.contentRef} />,

The child blocks can still contain rich text, images, and other block types.

`renderFrame:` An optional React component for styling the block and its children together, such as giving the callout a shared border or background. It receives `block`, `editor`, and `contentRef`, just like `render`. Attach `contentRef` where the block's content and children should appear. You can use the block's props to customize the frame, add interactive controls, or return `null` to show the block without a frame.
`renderFrame:` An optional React component for styling the block and its children together, such as giving the callout a shared border or background. It receives `block`, `editor`, and `slotRef`. Attach `slotRef` where the block's content and children should appear. You can use the block's props to customize the frame, add interactive controls, or return `null` to show the block without a frame.

`experimental_keyboard:` Without it, child blocks behave like any indented blocks. This API is experimental and may change. Here, `enter: "into-children"` makes Enter in the title add a first child block, `childrenCanOutdent: false` keeps Shift-Tab from moving blocks out of the callout, and `emptyChildEnter: "exit-at-end"` makes Enter in an empty last block leave the callout. See [Custom Blocks](/docs/features/custom-schemas/custom-blocks) for all keyboard settings.

For a container with `content: "none"`, like the panel above, add the surrounding styling directly in `render`.
A container with `content: "none"`, like the panel above, has only `renderFrame`, and it always draws its frame.

Add `callout: createCallout()` to your schema, then use `content` for the title and `children` for the blocks inside it:

Expand Down
17 changes: 14 additions & 3 deletions docs/content/docs/features/custom-schemas/custom-blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,17 @@ type ReactCustomBlockImplementation = {
editor: BlockNoteEditor;
contentRef?: (node: HTMLElement | null) => void;
}>;
renderFrame?: React.FC<{
block: Block;
editor: BlockNoteEditor;
slotRef: (node: HTMLElement | null) => void;
}>;
toExternalHTML?: React.FC<{
block: Block;
editor: BlockNoteEditor;
contentRef?: (node: HTMLElement | null) => void;
contest: { nestingLevel: number };
childrenRef: (node: HTMLElement | null) => void;
context: { nestingLevel: number };
}>;
parse?: (element: HTMLElement) => PartialBlock["props"] | undefined;
parseContent?: (options: {
Expand Down Expand Up @@ -180,9 +186,14 @@ type ReactCustomBlockImplementation = {

- `contentRef:` A React `ref` you can use to mark which element in your block is editable. This is only available if your block config contains `content: "inline"` or `content: "plain"`.

`toExternalHTML?:` This component is used whenever the block is being exported to HTML for use outside BlockNote, for example when copying it to the clipboard. If it's not defined, BlockNote will just use `render` for the HTML conversion. Takes the same props as `render` and an additional `context` prop, which is. an object with the following attributes:
`renderFrame?:` A component that draws a box around the block's content and its child blocks, with `slotRef` marking where they go. A container block (`container: true`) declares `renderFrame` instead of `render`. See [Container Blocks](/docs/features/custom-schemas/container-blocks).

`toExternalHTML?:` This component is used whenever the block is being exported to HTML for use outside BlockNote, for example when copying it to the clipboard. If it's not defined, BlockNote will just use `render` (and `renderFrame`, if the block has one) for the HTML conversion. Takes the same props as `render`, and two more:

- `childrenRef:` A React `ref` that marks where the block's child blocks go, for example inside a `<details>` element. Without it, the child blocks follow the block.

- `nestingLevel`: How deep the block being exported in nested inside other blocks. 0 means it's at the top level of the document.
- `context:` An object with the following attributes:
- `nestingLevel`: How deep the block being exported is nested inside other blocks. 0 means it's at the top level of the document.

<Callout type="info">
_Note that your component passed to `toExternalHTML` is rendered and
Expand Down
5 changes: 3 additions & 2 deletions examples/06-custom-schema/09-container-block/src/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const createPanel = createReactBlockSpec(
container: true,
},
{
// With no content of its own, contentRef receives the child blocks.
render: (props) => <div className="panel" ref={props.contentRef} />,
// With no content of its own, the panel draws only its frame. The slot
// (`slotRef`) receives its child blocks.
renderFrame: (props) => <div className="panel" ref={props.slotRef} />,
},
);
2 changes: 1 addition & 1 deletion examples/06-custom-schema/13-callout-block/src/Callout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const createCallout = createReactBlockSpec(
),
renderFrame: (props) => (
<div className={"callout"}>
<div className={"callout-slot"} ref={props.contentRef} />
<div className={"callout-slot"} ref={props.slotRef} />
</div>
),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import { defaultBlockSpecs } from "../../../../blocks/defaultBlocks.js";
import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import { createBlockSpec } from "../../../../schema/blocks/createSpec.js";

// The editor stays headless, so these blocks are never rendered. `render`
// only has to exist for `createBlockSpec` to accept the spec.
// The editor stays headless, so these blocks are never rendered.
// `renderFrame` only has to exist for `createBlockSpec` to accept the spec.
const container = (type: string, config: Record<string, unknown>) =>
createBlockSpec({ type, propSchema: {}, ...config } as any, {
render: () => {
renderFrame: () => {
throw new Error("not rendered in this suite");
},
})();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ const renderDiv = () => {
return { dom, contentDOM: dom };
};

const frameDiv = () => {
const dom = document.createElement("div");
return { dom, slot: dom };
};

const Callout = createBlockSpec(
{
type: "callout" as const,
Expand All @@ -19,7 +24,7 @@ const Callout = createBlockSpec(
content: "none",
container: true,
},
{ render: renderDiv },
{ renderFrame: frameDiv },
)();

const Grid = createBlockSpec(
Expand All @@ -33,7 +38,7 @@ const Grid = createBlockSpec(
min: 2,
},
},
{ render: renderDiv },
{ renderFrame: frameDiv },
)();

const GridCell = createBlockSpec(
Expand All @@ -44,7 +49,7 @@ const GridCell = createBlockSpec(
container: true,
placeable: "namedOnly",
},
{ render: renderDiv },
{ renderFrame: frameDiv },
)();

// A second container of any blocks, for conversions between container types.
Expand All @@ -55,7 +60,7 @@ const Box = createBlockSpec(
content: "none",
container: true,
},
{ render: renderDiv },
{ renderFrame: frameDiv },
)();

// A container that requires two callouts, so dropping below `min` dissolves
Expand All @@ -69,7 +74,7 @@ const Pair = createBlockSpec(
container: true,
children: { allow: ["callout"], min: 2 },
},
{ render: renderDiv },
{ renderFrame: frameDiv },
)();

// A titled block: an ordinary block with inline content (the title) whose
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -476,9 +476,9 @@ describe("repair edge cases", () => {
children: { allow: ["callout"], min: 0 },
},
{
render: () => {
renderFrame: () => {
const dom = document.createElement("div");
return { dom, contentDOM: dom };
return { dom, slot: dom };
},
},
)();
Expand Down
141 changes: 100 additions & 41 deletions packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
StyleSchema,
} from "../../../../schema/index.js";
import { isContainerNode } from "../../../../schema/blocks/children.js";
import { containerRootDOM } from "../../../../schema/blocks/createSpec.js";
import {
FRAME_SLOT_ATTRIBUTE,
renderFrameDOM,
} from "../../../../schema/blocks/frame.js";
import { isDocumentFragment } from "../../../../schema/blocks/internal.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
inlineContentToNodes,
Expand Down Expand Up @@ -178,6 +182,52 @@ export function serializeInlineContentExternalHTML<
return fragment;
}

/** A block's external HTML, before the serializer post-processes it. */
type BlockOutput = {
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
childrenDOM?: HTMLElement;
};

/**
* The block's output for external HTML, and the frame to put it into. The
* block's own `toExternalHTML` replaces the default export, frame included.
* By default, a container is its frame, with the children in the slot, and
* any other block is `render`'s output, which the caller puts into the
* block's frame (if it draws one).
*/
function renderBlockOutput(
blockImplementation: BlockImplementation,
block: any,
editor: BlockNoteEditor<any, any, any>,
isContainer: boolean,
nestingLevel: number,
): { output: BlockOutput; frame?: { dom: HTMLElement; slot: HTMLElement } } {
const external = blockImplementation.toExternalHTML?.call({}, block, editor, {
nestingLevel,
});
if (external) {
return { output: external };
}

const frame = renderFrameDOM(editor, block);
if (isContainer) {
if (!frame) {
throw new Error(
`Container block "${block.type}" drew no frame. This is a bug in BlockNote.`,
);
}
return { output: { dom: frame.dom, childrenDOM: frame.slot } };
}

if (!blockImplementation.render) {
throw new Error(
`Block "${block.type}" has no \`render\`. This is a bug in BlockNote.`,
);
}
return { output: blockImplementation.render.call({}, block, editor), frame };
}

/**
* TODO: there's still quite some logic that handles getting and filtering properties,
* we should make sure the `toExternalHTML` methods of default blocks actually handle this,
Expand Down Expand Up @@ -227,36 +277,26 @@ function serializeBlock<
const blockImplementation = editor.blockImplementations[block.type as any]
.implementation as BlockImplementation;
const blockWithDefaults = { ...block, props, children: block.children ?? [] };
const ret =
blockImplementation.toExternalHTML?.call(
{},
blockWithDefaults as any,
editor as any,
{
nestingLevel,
},
) ||
blockImplementation.render.call(
{},
blockWithDefaults as any,
editor as any,
);
const isContainer = isContainerNode(editor.pmSchema.nodes[block.type as any]);
const { output: ret, frame } = renderBlockOutput(
blockImplementation,
blockWithDefaults,
editor,
isContainer,
nestingLevel,
);

const elementFragment = doc.createDocumentFragment();

// React renders can return a fragment around the root element.
const rootElement = containerRootDOM(ret);

const blockContentRoot = rootElement?.classList.contains("bn-block-content")
? rootElement
: ret.contentDOM?.closest<HTMLElement>(".bn-block-content");

elementFragment.append(ret.dom);
let rootElement: HTMLElement | undefined;
if (!isDocumentFragment(ret.dom)) {
rootElement = ret.dom;
}

if (blockContentRoot) {
if (rootElement?.classList.contains("bn-block-content")) {
const blockContentDataAttributes = [
...attrs,
...Array.from(blockContentRoot.attributes),
...Array.from(rootElement.attributes),
].filter(
(attr) =>
attr.name.startsWith("data") &&
Expand All @@ -269,28 +309,36 @@ function serializeBlock<
);

for (const attr of blockContentDataAttributes) {
(blockContentRoot.firstChild! as HTMLElement).setAttribute(
(rootElement.firstChild! as HTMLElement).setAttribute(
attr.name,
attr.value,
);
}

addAttributesAndRemoveClasses(blockContentRoot.firstChild! as HTMLElement);
addAttributesAndRemoveClasses(rootElement.firstChild! as HTMLElement);
if (nestingLevel > 0) {
(blockContentRoot.firstChild! as HTMLElement).setAttribute(
(rootElement.firstChild! as HTMLElement).setAttribute(
"data-nesting-level",
nestingLevel.toString(),
);
}
// Unwrap the content in place, preserving any surrounding frame.
blockContentRoot.replaceWith(...Array.from(blockContentRoot.childNodes));
elementFragment.append(...Array.from(rootElement.childNodes));
} else {
if (isContainerNode(editor.pmSchema.nodes[block.type as any])) {
// Pasted external HTML gets fresh IDs; scope parsing to actual children.
elementFragment.append(ret.dom);
if (isContainer) {
// Like a regular block's type marker (`data-content-type`), a
// container's markers stay out of external HTML: it parses back only
// through the block's `parse`, like any other block. Its props stay as
// `data-*` attributes. This mirrors the unwrapping of a regular block's
// `.bn-block-content` above: the spec's hooks produce the editor's
// structure with its markers, and this serializer strips them.
// TODO: consider not adding the markers in the first place (only the
// node view and the internal serializer need them), instead of adding
// and then stripping them. If so, change it for regular blocks too
// (`wrapInBlockStructure`), so both kinds of block keep one rule.
rootElement?.removeAttribute("data-node-type");
rootElement?.removeAttribute("data-id");
const childrenDOM =
("childrenDOM" in ret && ret.childrenDOM) || ret.contentDOM;
childrenDOM?.setAttribute("data-children-of", block.type!);
ret.childrenDOM?.removeAttribute(FRAME_SLOT_ATTRIBUTE);
}
if (nestingLevel > 0) {
rootElement?.setAttribute("data-nesting-level", nestingLevel.toString());
Expand Down Expand Up @@ -326,6 +374,15 @@ function serializeBlock<
}
}

// Where the block's children go: where the block placed them, else in the
// slot of the frame around its content.
let childrenDOM = ret.childrenDOM;
if (frame) {
frame.slot.append(elementFragment);
elementFragment.append(frame.dom);
childrenDOM = frame.slot;
}

let listType = undefined;
if (orderedListItemBlockTypes.has(block.type!)) {
listType = "OL";
Expand Down Expand Up @@ -378,18 +435,20 @@ function serializeBlock<
}
}

if ("childrenDOM" in ret && ret.childrenDOM) {
if (childrenDOM) {
// block specifies where children should go (e.g. toggle blocks
// place children inside <details>)
ret.childrenDOM.append(childFragment);
// place children inside <details>, or a frame's slot)
childrenDOM.append(childFragment);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (
editor.pmSchema.nodes[block.type as any].isInGroup("blockContent")
editor.pmSchema.nodes[block.type as any].isInGroup("blockContent") ||
!ret.contentDOM
) {
// default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment
// default "blockContainer" style blocks are flattened (no "nested block" support) for externalHTML, so append the child fragment to the outer fragment.
// So does a container whose own export places its children nowhere.
fragment.append(childFragment);
} else {
// for columns / column lists, do use nesting
ret.contentDOM?.append(childFragment);
ret.contentDOM.append(childFragment);
}
}
}
Expand Down
Loading
Loading