Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion examples/06-custom-schema/06-toggleable-blocks/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Toggleable Custom Blocks

This example shows how to create custom blocks with a toggle button to show/hide their children, like with the default toggle heading and list item blocks. This is done using the use the `ToggleWrapper` component from `@blocknote/react`.
This example shows how to create custom blocks with a toggle button to show/hide their children, like with the default toggle heading and list item blocks. This is done by setting `meta.collapsible` on the block spec, which `CollapsibleExtension` picks up.

**Relevant Docs:**

Expand Down
19 changes: 8 additions & 11 deletions examples/06-custom-schema/06-toggleable-blocks/src/Toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defaultProps } from "@blocknote/core";
import { createReactBlockSpec, ToggleWrapper } from "@blocknote/react";
import { createReactBlockSpec } from "@blocknote/react";

// The Toggle block that we want to add to our editor.
export const ToggleBlock = createReactBlockSpec(
Expand All @@ -11,15 +11,12 @@ export const ToggleBlock = createReactBlockSpec(
content: "inline",
},
{
render: (props) => (
// The `ToggleWrapper` component renders a button on the left which
// toggles the visibility of the block's children. It also adds a button
// to add child blocks if there are none. By default, it uses local
// storage to remember the toggled state based on the block ID, but you can pass a custom
// `toggledState` prop to use a different storage mechanism.
<ToggleWrapper block={props.block} editor={props.editor}>
<p ref={props.contentRef} />
</ToggleWrapper>
),
// `meta.collapsible` is all it takes: `CollapsibleExtension` adds the
// chevron that hides the block's children, and remembers the state per
// block ID in local storage.
meta: {
collapsible: true,
},
render: (props) => <p ref={props.contentRef} />,
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { Node as PMNode } from "prosemirror-model";
import { TextSelection, type Transaction } from "prosemirror-state";

import {
getBlockInfo,
getNearestBlockPos,
} from "../../../getBlockInfoFromPos.js";
import { getPmSchema } from "../../../pmUtil.js";

/**
* Inserts `content` before whatever children the block at `blockPos` already
* has, creating the `blockGroup` that holds them if there aren't any yet.
*
* That group is `content: "blockGroupChild+"`, so it can't exist empty: a
* childless block doesn't have one, and there is no position inside it to
* insert at. Every path that puts the first block into such a block has to
* create the group and its first occupant together, which is what this does —
* a block that already has children just gets them prepended.
*
* @returns The position just before the first inserted child block. Use
* `TextSelection.near(tr.doc.resolve(pos), 1)` to put the text cursor in it.
*/
export function materializeChildren(
tr: Transaction,
blockPos: number,
content: PMNode | readonly PMNode[],
): number {
const info = getBlockInfo(getNearestBlockPos(tr.doc, blockPos));

const nodes = Array.isArray(content) ? content : [content as PMNode];

if (info.childContainer) {
const insertPos = info.childContainer.beforePos + 1;
tr.insert(insertPos, nodes);

return insertPos;
}

// No group yet, so it's created here along with its first occupant. It goes
// at the very end of the block, just inside it — which for a `blockContainer`
// is right after its content node, and only a `blockContainer` can be without
// a child container in the first place (columns and column lists always have
// one).
const schema = getPmSchema(tr);
const insertPos = info.bnBlock.afterPos - 1;
tr.insert(insertPos, schema.nodes["blockGroup"].create(null, nodes));

// `insertPos` is just before the new `blockGroup`, so the first child starts
// one position further in.
return insertPos + 1;
}

/**
* Inserts an empty paragraph as the first child of the block at `blockPos` and
* puts the text cursor in it.
*/
export function insertEmptyFirstChild(tr: Transaction, blockPos: number) {
const schema = getPmSchema(tr);
const childPos = materializeChildren(
tr,
blockPos,
schema.nodes["blockContainer"].createAndFill(
undefined,
schema.nodes["paragraph"].createAndFill() ?? undefined,
)!,
);

tr.setSelection(TextSelection.near(tr.doc.resolve(childPos), 1));
tr.scrollIntoView();
}
Original file line number Diff line number Diff line change
Expand Up @@ -621,23 +621,6 @@ exports[`Test splitBlocks > Block has children 1`] = `
},
"type": "paragraph",
},
{
"children": [],
"content": [
{
"styles": {},
"text": "Para",
"type": "text",
},
],
"id": "paragraph-with-children",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
"textColor": "default",
},
"type": "paragraph",
},
{
"children": [
{
Expand Down Expand Up @@ -676,6 +659,23 @@ exports[`Test splitBlocks > Block has children 1`] = `
"type": "paragraph",
},
],
"content": [
{
"styles": {},
"text": "Para",
"type": "text",
},
],
"id": "paragraph-with-children",
"props": {
"backgroundColor": "default",
"textAlignment": "left",
"textColor": "default",
},
"type": "paragraph",
},
{
"children": [],
"content": [
{
"styles": {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,30 @@ describe("Test splitBlocks", () => {
expect(getEditor().document).toMatchSnapshot();
});

it("Block has children, split at end of content", () => {
getEditor().transact((tr) => {
setSelectionWithOffset(
tr.doc,
"paragraph-with-children",
"Paragraph with children".length,
);
});

splitBlock(getEditor().transact((tr) => tr.selection.anchor));

// The children must stay with the original block rather than being
// reparented onto the newly created one.
const original = getEditor().document.find(
(block) => block.id === "paragraph-with-children",
)!;
expect(original.children.map((child) => child.id)).toEqual([
"nested-paragraph-0",
]);

const newBlock = getEditor().document.find((block) => block.id === "0")!;
expect(newBlock.children).toEqual([]);
});

it("Keep type", () => {
getEditor().transact((tr) => {
setSelectionWithOffset(tr.doc, "heading-0", 4);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,37 @@ export const splitBlockTr = (
},
];

// A block's children live in a `blockGroup` that sits *after* its content
// inside the `blockContainer`. A plain split would therefore hand that group
// to the new block, i.e. the new block would steal the original's children.
// To avoid that, the group is detached before the split and put back on the
// original block afterwards. Both happen in the same transaction, so this is
// still a single undo step.
const childContainer = info.childContainer;

if (childContainer) {
// The group sits after `posInBlock`, so deleting it doesn't shift the split
// position.
tr.delete(childContainer.beforePos, childContainer.afterPos);
}

tr.split(posInBlock, 2, types);

if (childContainer) {
// The original block starts before `posInBlock`, so its position is
// unaffected by the delete and the split.
const originalBlockInfo = getBlockInfo(
getNearestBlockPos(tr.doc, nearestBlockContainerPos.posBeforeNode),
);

if (!originalBlockInfo.isBlockContainer) {
throw new Error(
"Block that was just split is no longer a block container",
);
}

tr.insert(originalBlockInfo.blockContent.afterPos, childContainer.node);
}

return true;
};
16 changes: 0 additions & 16 deletions packages/core/src/api/exporters/html/internalHTMLSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,21 +55,6 @@ const makeCheckListItemsReadOnly = (element: HTMLElement) => {
return element;
};

// Forces toggle blocks (toggle headings, toggle list items) to be expanded.
// This is because event listeners for the toggle button are lost when
// serializing HTML elements to a string, so the button no longer works if the
// HTML string is rendered out.
const forceToggleBlocksShow = (element: HTMLElement) => {
const hiddenToggleWrappers = element.querySelectorAll(
'.bn-toggle-wrapper[data-show-children="false"]',
);
hiddenToggleWrappers.forEach((toggleWrapper) => {
toggleWrapper.setAttribute("data-show-children", "true");
});

return element;
};

// Adds minimum cell widths, which would normally be done by the
// `columnResizing` extension. This extension doesn't run when exporting to
// HTML, so we have to add this manually.
Expand Down Expand Up @@ -154,7 +139,6 @@ export const createInternalHTMLSerializer = <
const transforms: ((element: HTMLElement) => HTMLElement)[] = [
addIndexToNumberedListItems,
makeCheckListItemsReadOnly,
forceToggleBlocksShow,
addTableMinCellWidths,
addTableWrappers,
addTrailingBreakToEmptyInlineContent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { DOMSerializer, Fragment, Node } from "prosemirror-model";

import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
createCollapseButton,
isBlockCollapsible,
} from "../../../../extensions/Collapsible/Collapsible.js";
import {
BlockSchema,
InlineContentSchema,
Expand Down Expand Up @@ -197,6 +201,18 @@ function serializeBlock<
contentDOM?: HTMLElement;
};

// `CollapsibleExtension` doesn't run when exporting, so the chevron is added
// here to keep the layout identical to the editor's. It can't fold anything,
// so it's rendered inert.
if (isBlockCollapsible(editor as any, block.type as string, props)) {
const collapseButton = createCollapseButton(true);
collapseButton.setAttribute("aria-hidden", "true");
collapseButton.setAttribute("tabindex", "-1");

bc.dom.setAttribute("data-collapsible", "true");
bc.contentDOM?.appendChild(collapseButton);
}

bc.contentDOM?.appendChild(ret.dom);

if (block.children && block.children.length > 0) {
Expand Down
10 changes: 3 additions & 7 deletions packages/core/src/blocks/Heading/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
parseDefaultProps,
} from "../defaultProps.js";
import { getDetailsContent } from "../getDetailsContent.js";
import { createToggleWrapper } from "../ToggleWrapper/createToggleWrapper.js";

const HEADING_LEVELS = [1, 2, 3, 4, 5, 6] as const;

Expand Down Expand Up @@ -63,6 +62,8 @@ export const createHeadingBlockSpec = createBlockSpec(
({ allowToggleHeadings = true }: HeadingOptions = {}) => ({
meta: {
isolating: false,
// Handled by `CollapsibleExtension`.
collapsible: (block) => allowToggleHeadings && !!block.props.isToggleable,
},
parse(e) {
if (allowToggleHeadings && e.tagName === "DETAILS") {
Expand Down Expand Up @@ -126,14 +127,9 @@ export const createHeadingBlockSpec = createBlockSpec(
}
: {}),
runsBefore: ["toggleListItem"],
render(block, editor) {
render(block) {
const dom = document.createElement(`h${block.props.level}`);

if (allowToggleHeadings) {
const toggleWrapper = createToggleWrapper(block, editor, dom);
return { ...toggleWrapper, contentDOM: dom };
}

return {
dom,
contentDOM: dom,
Expand Down
15 changes: 7 additions & 8 deletions packages/core/src/blocks/ListItem/ToggleListItem/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
parseDefaultProps,
} from "../../defaultProps.js";
import { getDetailsContent } from "../../getDetailsContent.js";
import { createToggleWrapper } from "../../ToggleWrapper/createToggleWrapper.js";
import { handleEnter } from "../../utils/listItemEnterHandler.js";

export type ToggleListItemBlockConfig = ReturnType<
Expand All @@ -29,6 +28,7 @@ export const createToggleListItemBlockSpec = createBlockSpec(
{
meta: {
isolating: false,
collapsible: true,
},
parse(element) {
if (element.tagName === "DETAILS") {
Expand Down Expand Up @@ -71,14 +71,13 @@ export const createToggleListItemBlockSpec = createBlockSpec(
);
},
runsBefore: ["bulletListItem"],
render(block, editor) {
render() {
const paragraphEl = document.createElement("p");
const toggleWrapper = createToggleWrapper(
block as any,
editor,
paragraphEl,
);
return { ...toggleWrapper, contentDOM: paragraphEl };

return {
dom: paragraphEl,
contentDOM: paragraphEl,
};
},
toExternalHTML(block) {
const li = document.createElement("li");
Expand Down
Loading
Loading