Skip to content
Open
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
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,50 @@ 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("Block has children, split at start of content", () => {
getEditor().transact((tr) => {
setSelectionWithOffset(tr.doc, "paragraph-with-children", 0);
});

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

// The first half keeps no content, so the children go with the second one,
// which does — rather than being stranded on an empty block.
const original = getEditor().document.find(
(block) => block.id === "paragraph-with-children",
)!;
expect(original.children).toEqual([]);

const newBlock = getEditor().document.find((block) => block.id === "0")!;
expect(newBlock.children.map((child) => child.id)).toEqual([
"nested-paragraph-0",
]);
});

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,43 @@ 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.
//
// Splitting at the very start is the exception: there the first half keeps no
// content at all and the whole of it moves to the second, so the children
// follow it rather than being left behind on an empty block. That's what a
// plain `tr.split` already does.
const splitAtStart = posInBlock === info.blockContent.beforePos + 1;
const childContainer = splitAtStart ? undefined : 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, 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