Skip to content

Commit 0bce75a

Browse files
committed
fix(container-blocks): simplify editing and support plain text
1 parent 56a63d0 commit 0bce75a

27 files changed

Lines changed: 1084 additions & 435 deletions

File tree

‎docs/content/docs/features/custom-schemas/container-blocks.mdx‎

Lines changed: 126 additions & 157 deletions
Large diffs are not rendered by default.

‎docs/content/docs/features/custom-schemas/custom-blocks.mdx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ type BlockConfig = {
5656
type: string;
5757
content: "inline" | "plain" | "none";
5858
readonly propSchema: PropSchema;
59+
children?: {
60+
allow: "blocks" | string[];
61+
min?: number;
62+
};
63+
placeable?: "anywhere" | "namedOnly";
5964
};
6065
```
6166

@@ -73,11 +78,13 @@ type BlockConfig = {
7378
</Callout>
7479

7580
<Callout type="info">
76-
_A `content: "none"` block can also hold other blocks as its body by
77-
declaring the `children` option. See [Container
81+
_Custom blocks can also contain child blocks by declaring the `children`
82+
option, with or without editable content of their own. See [Container
7883
Blocks](/docs/features/custom-schemas/container-blocks)._
7984
</Callout>
8085

86+
`children?:` Defines which child blocks the block can contain. `placeable?:` Controls where a container block can be used. See [Container Blocks](/docs/features/custom-schemas/container-blocks) for the supported configurations.
87+
8188
`propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior.
8289

8390
```typescript

‎docs/content/docs/reference/editor/manipulating-content.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ insertBlocks(
145145
): Block[]
146146
```
147147

148-
Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"first-child"` and `"last-child"` nest them inside it. Returns the inserted blocks. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container).
148+
Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"first-child"` and `"last-child"` nest them inside it. Returns the inserted blocks.
149149

150150
```typescript
151151
// Insert a paragraph before an existing block
@@ -173,6 +173,8 @@ editor.insertBlocks(
173173
);
174174
```
175175

176+
For [container blocks](/docs/features/custom-schemas/container-blocks), `"first-child"` inserts at the beginning of the container and `"last-child"` inserts at the end. Use `"before"` or `"after"` to insert next to the container instead.
177+
176178
### Updating Blocks
177179

178180
#### Modifying Existing Blocks

‎examples/06-custom-schema/09-container-block/src/App.tsx‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
22
import {
3+
SideMenuExtension,
34
filterSuggestionItems,
45
insertOrUpdateBlockForSlashMenu,
56
} from "@blocknote/core/extensions";
67
import "@blocknote/core/fonts/inter.css";
78
import { BlockNoteView } from "@blocknote/mantine";
89
import "@blocknote/mantine/style.css";
910
import {
11+
SideMenu,
12+
SideMenuController,
1013
SuggestionMenuController,
1114
getDefaultReactSlashMenuItems,
15+
useBlockNoteEditor,
1216
useCreateBlockNote,
17+
useEditorState,
18+
useExtensionState,
1319
} from "@blocknote/react";
1420
import { useEffect, useState } from "react";
1521
import { RiChatQuoteLine } from "react-icons/ri";
@@ -41,6 +47,30 @@ const insertPanel = (editor: typeof schema.BlockNoteEditor) => ({
4147

4248
type AppBlock = (typeof schema.BlockNoteEditor)["document"][number];
4349

50+
function PanelSideMenu() {
51+
const editor = useBlockNoteEditor<
52+
typeof schema.blockSchema,
53+
typeof schema.inlineContentSchema,
54+
typeof schema.styleSchema
55+
>();
56+
const blockId = useExtensionState(SideMenuExtension, {
57+
selector: (state) => state?.block.id,
58+
});
59+
const isFirstPanelChild = useEditorState({
60+
editor,
61+
selector: () => {
62+
if (!blockId || !editor.getBlock(blockId)) {
63+
return false;
64+
}
65+
const parent = editor.getParentBlock(blockId);
66+
return parent?.type === "panel" && parent.children[0]?.id === blockId;
67+
},
68+
});
69+
70+
// The first child's gutter is occupied by the panel's flavor button.
71+
return isFirstPanelChild ? null : <SideMenu />;
72+
}
73+
4474
export default function App() {
4575
const [blocks, setBlocks] = useState<AppBlock[]>([]);
4676

@@ -55,6 +85,11 @@ export default function App() {
5585
type: "panel",
5686
props: { flavor: "tip" },
5787
children: [
88+
{
89+
type: "heading",
90+
props: { level: 3 },
91+
content: "More than paragraphs",
92+
},
5893
{
5994
type: "paragraph",
6095
content: "Panels can hold any block as their body.",
@@ -85,10 +120,12 @@ export default function App() {
85120
<BlockNoteView
86121
editor={editor}
87122
slashMenu={false}
123+
sideMenu={false}
88124
onChange={() => {
89125
setBlocks(editor.document);
90126
}}
91127
>
128+
<SideMenuController sideMenu={PanelSideMenu} />
92129
<SuggestionMenuController
93130
triggerCharacter={"/"}
94131
getItems={async (query) => {

‎examples/06-custom-schema/09-container-block/src/styles.css‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
}
2626

2727
.panel {
28+
/* The default flavor is omitted from the block's serialized attributes. */
29+
--panel-accent: #b45309;
30+
--panel-bg: #fff7ed;
2831
display: flex;
2932
align-items: flex-start;
3033
gap: 12px;
@@ -36,12 +39,12 @@
3639
}
3740

3841
.panel[data-flavor="tip"] {
39-
--panel-accent: #d97706;
42+
--panel-accent: #b45309;
4043
--panel-bg: #fff7ed;
4144
}
4245

4346
.panel[data-flavor="info"] {
44-
--panel-accent: #507aff;
47+
--panel-accent: #365bcc;
4548
--panel-bg: #e6ebff;
4649
}
4750

@@ -51,23 +54,28 @@
5154
}
5255

5356
.panel[data-flavor="success"] {
54-
--panel-accent: #16a34a;
57+
--panel-accent: #15803d;
5558
--panel-bg: #ecfdf5;
5659
}
5760

61+
[data-color-scheme="dark"] .panel,
5862
[data-color-scheme="dark"] .panel[data-flavor="tip"] {
63+
--panel-accent: #fbbf24;
5964
--panel-bg: #432e0e;
6065
}
6166

6267
[data-color-scheme="dark"] .panel[data-flavor="info"] {
68+
--panel-accent: #93b4ff;
6369
--panel-bg: #1e2a5c;
6470
}
6571

6672
[data-color-scheme="dark"] .panel[data-flavor="warning"] {
73+
--panel-accent: #fca5a5;
6774
--panel-bg: #4a1212;
6875
}
6976

7077
[data-color-scheme="dark"] .panel[data-flavor="success"] {
78+
--panel-accent: #86efac;
7179
--panel-bg: #0d3b21;
7280
}
7381

@@ -84,7 +92,12 @@
8492
}
8593

8694
.panel-icon-button:hover {
87-
opacity: 0.75;
95+
background-color: var(--bn-colors-hovered-background);
96+
}
97+
98+
.panel-icon-button:focus-visible {
99+
outline: 2px solid currentColor;
100+
outline-offset: 2px;
88101
}
89102

90103
.panel-body {

‎packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,45 @@
1-
import { EditorState } from "prosemirror-state";
1+
import { Fragment, type Node } from "prosemirror-model";
2+
import { EditorState, TextSelection } from "prosemirror-state";
23

34
import {
5+
type BlockInfo,
46
getBlockInfoAt,
57
getLastDescendantBlockInfo,
68
getPrevBlockInfo,
79
getParentBlockInfo,
810
} from "../../../getBlockInfoFromPos.js";
911

12+
/** Returns compatible text to append, or undefined when the blocks cannot merge. */
13+
export function getMergeContent(
14+
current: Extract<BlockInfo, { hasContent: true }>,
15+
next: Extract<BlockInfo, { hasContent: true }>,
16+
): Fragment | undefined {
17+
const inline =
18+
current.contentKind === "inline" && next.contentKind === "inline";
19+
const ownedText =
20+
current.hasOwnedChildren &&
21+
current.content.node.isTextblock &&
22+
next.content.node.isTextblock;
23+
if (!inline && !ownedText) {
24+
return undefined;
25+
}
26+
if (current.contentKind === "plain") {
27+
const type = current.content.node.type;
28+
const children: Node[] = [];
29+
next.content.node.forEach((child) => {
30+
const text =
31+
child.type === type.schema.linebreakReplacement
32+
? "\n"
33+
: child.textContent;
34+
if (text) {
35+
children.push(type.schema.text(text, type.allowedMarks(child.marks)));
36+
}
37+
});
38+
return Fragment.from(children);
39+
}
40+
return next.content.node.content;
41+
}
42+
1043
/**
1144
* Merges the block starting at `posBetweenBlocks` into the block visually
1245
* above it, by deleting the boundary between the two.
@@ -16,9 +49,9 @@ import {
1649
* i.e. its `BlockInfo`'s `block.beforePos`. The block above is found by walking
1750
* back from there.
1851
* @returns A tiptap command that returns `false` (leaving the doc untouched)
19-
* when the two blocks can't merge: no block above, either side isn't an
20-
* inline-content block, or the block above is empty (deleting it is handled
21-
* elsewhere).
52+
* when the two blocks can't merge: no compatible text block above, or the
53+
* block above is empty (deleting it is handled elsewhere). An owning block
54+
* can also merge plain text, dropping formatting that its schema disallows.
2255
*/
2356
export const mergeBlocksCommand =
2457
(posBetweenBlocks: number) =>
@@ -51,13 +84,15 @@ export const mergeBlocksCommand =
5184

5285
if (
5386
!prevBlockInfo.hasContent ||
54-
prevBlockInfo.contentKind !== "inline" ||
5587
prevBlockInfo.isContentEmpty ||
56-
!nextBlockInfo.hasContent ||
57-
nextBlockInfo.contentKind !== "inline"
88+
!nextBlockInfo.hasContent
5889
) {
5990
return false;
6091
}
92+
const content = getMergeContent(prevBlockInfo, nextBlockInfo);
93+
if (content === undefined) {
94+
return false;
95+
}
6196

6297
// Lift children before removing their parent. Tiptap's chainable state
6398
// returns the shared transaction, so the lift is included in dispatch.
@@ -82,9 +117,20 @@ export const mergeBlocksCommand =
82117
}
83118

84119
if (dispatch) {
85-
dispatch(
86-
state.tr.delete(prevBlockInfo.contentEnd, nextBlockInfo.contentStart),
87-
);
120+
if (content !== nextBlockInfo.content.node.content) {
121+
state.tr
122+
.replaceWith(
123+
prevBlockInfo.contentEnd,
124+
nextBlockInfo.contentEnd,
125+
content,
126+
)
127+
.setSelection(
128+
TextSelection.create(state.tr.doc, prevBlockInfo.contentEnd),
129+
);
130+
} else {
131+
state.tr.delete(prevBlockInfo.contentEnd, nextBlockInfo.contentStart);
132+
}
133+
dispatch(state.tr);
88134
}
89135

90136
return true;

‎packages/core/src/api/blockManipulation/containers/containers.test.ts‎

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -382,32 +382,33 @@ describe("repair edge cases", () => {
382382
},
383383
)();
384384

385-
const trayEditor = BlockNoteEditor.create({
386-
schema: containerSchema.extend({
387-
blockSpecs: { tray: Tray },
388-
}),
389-
}) as BlockNoteEditor<any, any, any>;
390-
391385
it("keeps a `min: 0` container with zero children", () => {
392-
trayEditor.replaceBlocks(trayEditor.document, [
393-
{
394-
type: "tray",
395-
id: "t-0",
396-
children: [{ id: "t-p-0", type: "paragraph", content: "" }],
397-
},
398-
{ id: "trailing", type: "paragraph", content: "" },
399-
]);
400-
401-
// Removing its only (empty) child leaves zero children, which `min: 0`
402-
// allows: the tray stays instead of dissolving, and nothing is padded
403-
// back.
404-
trayEditor.removeBlocks(["t-p-0"]);
405-
406-
expect(trayEditor.getBlock("t-0")).toBeDefined();
407-
expect(trayEditor.getBlock("t-0")!.children).toHaveLength(0);
408-
expect(() => trayEditor.prosemirrorState.doc.check()).not.toThrow();
409-
410-
trayEditor._tiptapEditor.destroy();
386+
const trayEditor = BlockNoteEditor.create({
387+
schema: containerSchema.extend({
388+
blockSpecs: { tray: Tray },
389+
}),
390+
});
391+
try {
392+
trayEditor.replaceBlocks(trayEditor.document, [
393+
{
394+
type: "tray",
395+
id: "t-0",
396+
children: [{ id: "t-p-0", type: "paragraph", content: "" }],
397+
},
398+
{ id: "trailing", type: "paragraph", content: "" },
399+
]);
400+
401+
// Removing its only (empty) child leaves zero children, which `min: 0`
402+
// allows: the tray stays instead of dissolving, and nothing is padded
403+
// back.
404+
trayEditor.removeBlocks(["t-p-0"]);
405+
406+
expect(trayEditor.getBlock("t-0")).toBeDefined();
407+
expect(trayEditor.getBlock("t-0")!.children).toHaveLength(0);
408+
expect(() => trayEditor.prosemirrorState.doc.check()).not.toThrow();
409+
} finally {
410+
trayEditor._tiptapEditor.destroy();
411+
}
411412
});
412413

413414
it("keeps emptied regular blocks when the container survives", () => {

0 commit comments

Comments
 (0)