Skip to content

Commit 06a3cdb

Browse files
committed
docs: add a Containers concept page
Containers (block objects with editable child arrays: callouts, code blocks, tables) had no page on the docs site despite being the third core editor concept next to the schema and Behaviors, and despite shipping consumers (`plugin-table`, the playground's callout/code-block/ fact-box/table examples). Add `editor/concepts/containers` covering: the value model (a container is plain Portable Text, no editor-specific format), declaring the schema with a nested `{type: 'block'}` member and the three sub-schema resolution rules (declared / declared-empty / absent), registering with `defineContainer` + `NodePlugin` (render contract: spread `attributes`, render `children`, `contentEditable={false}` chrome, `renderDefault` fallback, stable `nodes` identity), and nesting through `of` with the table example from `defineContainer`'s own jsdoc. A render-pipeline section covers what registering opts you into: your render owns the outer wrapper (legacy block-level render props don't compose; span-level props and range decorations keep working), with a combined example registering the kinds through one `NodePlugin` (`defineContainer`, `defineTextBlock` with `type: 'block'`, which is the text block type at every nesting level, and `defineBlockObject`, which must render its `children`). The consequences get their remedies: list wrapping and numbering via `plugin-list-index`, drop indicators via `plugin-dnd`, with a worked text-block render composing both hooks. A migration subsection routes the legacy props into registrations: catch-all `defineBlockObject`/`defineInlineObject` registrations (`type: '*'`) as the direct equivalents of the generic `renderBlock`/`renderChild` callbacks, and a `type: 'block'` text-block registration reproducing the legacy nesting order (style innermost, list item, block outermost) as read from `render.text-block.tsx`. The editing-semantics section carries a worked example (a code-block sub-schema forbidding decorators plus a schema-lookup-configured Markdown shortcut, showing the schema acting as the feature flag), followed by registration validation (warn-and-skip naming the mismatch) and pointers to `plugin-table` and the playground. Registered in the Concepts sidebar group after Behaviors. Built with `CHECK_LINKS=1`, all links validate.
1 parent c713c21 commit 06a3cdb

2 files changed

Lines changed: 351 additions & 0 deletions

File tree

apps/docs/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export default defineConfig({
112112
items: [
113113
{slug: 'editor/concepts/portabletext'},
114114
{slug: 'editor/concepts/behavior'},
115+
{slug: 'editor/concepts/containers'},
115116
],
116117
},
117118
{
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
---
2+
title: Containers
3+
description: Nest editable rich text inside custom structures like callouts, code blocks, and tables, while the value stays plain Portable Text.
4+
sidebar:
5+
order: 2
6+
---
7+
8+
A container is a block object that holds editable rich text: one of its fields is an array whose members include text blocks (or further containers). Containers are how callouts carry editable paragraphs, code blocks carry editable lines, and tables carry editable cells, without leaving Portable Text.
9+
10+
## Containers are plain Portable Text
11+
12+
There is no editor-specific data format. A container in the value is an ordinary object block whose field happens to hold more blocks:
13+
14+
```json
15+
{
16+
"_type": "callout",
17+
"_key": "a1b2c3",
18+
"tone": "note",
19+
"content": [
20+
{
21+
"_type": "block",
22+
"_key": "d4e5f6",
23+
"children": [
24+
{
25+
"_type": "span",
26+
"_key": "g7h8i9",
27+
"text": "Editable text inside the callout.",
28+
"marks": []
29+
}
30+
],
31+
"markDefs": [],
32+
"style": "normal"
33+
}
34+
]
35+
}
36+
```
37+
38+
Serializers and queries see nested Portable Text, nothing more. What makes it a _container_ is that the editor knows to render the `content` array as an editable region instead of treating `callout` as an opaque block object.
39+
40+
## Declare the schema
41+
42+
A container starts in the schema: a block object with an array field whose `of` includes a `{type: 'block'}` member.
43+
44+
```ts
45+
import {defineSchema} from '@portabletext/editor'
46+
47+
const schemaDefinition = defineSchema({
48+
decorators: [{name: 'strong'}, {name: 'em'}],
49+
blockObjects: [
50+
{
51+
name: 'callout',
52+
fields: [
53+
{name: 'tone', type: 'string'},
54+
{name: 'content', type: 'array', of: [{type: 'block'}]},
55+
],
56+
},
57+
],
58+
})
59+
```
60+
61+
The nested `{type: 'block'}` member declares the _sub-schema_ for text inside the container. Each of `styles`, `decorators`, `annotations`, `lists`, and `inlineObjects` resolves independently:
62+
63+
- **Declared** on the nested block, it overrides for that property.
64+
- **Declared empty** (`decorators: []`), it forbids that property inside the container.
65+
- **Absent**, it inherits from the nearest enclosing container that declares one, falling back to the root schema.
66+
67+
This is how a code block restricts its lines to a `code` style with no decorators while the rest of the document keeps its full schema. The complete resolution rules live in the [`@portabletext/schema` README](https://github.com/portabletext/editor/tree/main/packages/schema#containers-and-sub-schemas).
68+
69+
## Register the container
70+
71+
The schema declares what a container _allows_; a registration tells the editor to _render_ it as one. Create the registration with `defineContainer` and mount it with `NodePlugin`:
72+
73+
```tsx
74+
import {
75+
defineContainer,
76+
EditorProvider,
77+
PortableTextEditable,
78+
} from '@portabletext/editor'
79+
import {NodePlugin} from '@portabletext/editor/plugins'
80+
81+
// Module scope: a new array identity re-registers the nodes on every render.
82+
const nodes = [
83+
defineContainer({
84+
type: 'callout',
85+
arrayField: 'content',
86+
render: ({attributes, children, selected}) => (
87+
<aside {...attributes} data-selected={selected ? '' : undefined}>
88+
<span contentEditable={false}>💡</span>
89+
{children}
90+
</aside>
91+
),
92+
}),
93+
]
94+
95+
function App() {
96+
return (
97+
<EditorProvider initialConfig={{schemaDefinition}}>
98+
<NodePlugin nodes={nodes} />
99+
<PortableTextEditable />
100+
</EditorProvider>
101+
)
102+
}
103+
```
104+
105+
The render contract:
106+
107+
- Spread `attributes` onto your outer element so the engine can track the node.
108+
- Render `children` where the editable content goes; the engine fills it with the container's blocks.
109+
- Mark any chrome that is not editable content (icons, buttons, drag handles) with `contentEditable={false}`.
110+
- `renderDefault` renders the engine's minimal wrapper; call it to fall back or wrap the default.
111+
112+
Omitting `render` falls through to the engine default, so a registration can exist purely to mark the field as editable.
113+
114+
## Nest containers
115+
116+
Containers nest through the `of` array, which scopes registrations to positions inside the parent. A table is three containers deep:
117+
118+
```tsx
119+
defineContainer({
120+
type: 'table',
121+
arrayField: 'rows',
122+
render: ({children}) => <table>{children}</table>,
123+
of: [
124+
defineContainer({
125+
type: 'row',
126+
arrayField: 'cells',
127+
render: ({children}) => <tr>{children}</tr>,
128+
of: [
129+
defineContainer({
130+
type: 'cell',
131+
arrayField: 'content',
132+
render: ({attributes, children}) => (
133+
<td {...attributes}>{children}</td>
134+
),
135+
}),
136+
],
137+
}),
138+
],
139+
})
140+
```
141+
142+
`of` also accepts `defineTextBlock` registrations, so a container can render its text blocks differently from the rest of the document, a code block rendering each line without paragraph spacing, for example.
143+
144+
## Registrations opt into the new render pipeline
145+
146+
Registering nodes with `NodePlugin` opts those positions into the editor's new render pipeline, and that changes who owns what. Your `render` owns the outer wrapper entirely: the engine emits only `data-pt-*` attributes, and the block-level render props on `<PortableTextEditable>`, `renderBlock`, `renderStyle`, `renderListItem`, and `renderChild`, do not compose for registered nodes. Span-level rendering keeps working: `renderDecorator`, `renderAnnotation`, `renderPlaceholder`, and range decorations fire on the spans inside `children` regardless of who renders the block.
147+
148+
Each kind of node has its own registration factory, and they mount together through one `NodePlugin`:
149+
150+
```tsx
151+
import {
152+
defineBlockObject,
153+
defineContainer,
154+
defineTextBlock,
155+
} from '@portabletext/editor'
156+
157+
const nodes = [
158+
// A container: a block object with an editable child array.
159+
defineContainer({
160+
type: 'callout',
161+
arrayField: 'content',
162+
render: ({attributes, children}) => (
163+
<aside {...attributes}>{children}</aside>
164+
),
165+
}),
166+
// Text blocks. `block` is the text block type at every nesting level,
167+
// so this one registration covers paragraphs at the top level and
168+
// inside the callout alike.
169+
defineTextBlock({
170+
type: 'block',
171+
render: ({attributes, children, node}) => (
172+
<div {...attributes} data-style={node.style}>
173+
{children}
174+
</div>
175+
),
176+
}),
177+
// A non-editable void block. Render `children` as well: the engine
178+
// mounts its internals through them.
179+
defineBlockObject({
180+
type: 'image',
181+
render: ({attributes, children, node}) => (
182+
<figure {...attributes}>
183+
<img src={String(node.src)} alt="" contentEditable={false} />
184+
{children}
185+
</figure>
186+
),
187+
}),
188+
]
189+
```
190+
191+
Owning the wrapper means owning things the engine used to do for you. The two that surprise people:
192+
193+
**List rendering.** The engine's default list-item wrapping and ordered-list numbering do not apply to custom text-block renders. Your render handles `node.listItem` and `node.level`, and [`@portabletext/plugin-list-index`](https://github.com/portabletext/editor/tree/main/packages/plugin-list-index) computes the 1-based list index at any path, correct across nesting, remote edits, and non-list blocks interrupting a list.
194+
195+
**Drop indicators.** The engine renders no drop-indicator chrome for registered nodes, where a dragged block would land is pointer-driven UI, and it's deliberately yours. [`@portabletext/plugin-dnd`](https://github.com/portabletext/editor/tree/main/packages/plugin-dnd) tracks the drop position from the editor's public `drag.*` events.
196+
197+
Both plugins follow the same pattern: a provider inside `EditorProvider`, and a hook read from your render, called from a component the render returns, not inline in the `render` callback, since hooks can't run there:
198+
199+
```tsx
200+
import type {TextBlockRenderProps} from '@portabletext/editor'
201+
import {DndProvider, useDropPosition} from '@portabletext/plugin-dnd'
202+
import {ListIndexProvider, useListIndex} from '@portabletext/plugin-list-index'
203+
204+
const nodes = [
205+
defineTextBlock({
206+
type: 'block',
207+
render: (props) => <TextBlock {...props} />,
208+
}),
209+
]
210+
211+
function TextBlock(props: TextBlockRenderProps) {
212+
// The 1-based position within the list, or `undefined` for a block
213+
// that is not a list item.
214+
const listIndex = useListIndex(props.path)
215+
// `'start' | 'end'` while a block drag hovers this block.
216+
const dropPosition = useDropPosition(props.path)
217+
218+
return (
219+
<div {...props.attributes} style={{position: 'relative'}}>
220+
{listIndex !== undefined ? (
221+
<span contentEditable={false}>{listIndex}. </span>
222+
) : null}
223+
{props.children}
224+
{dropPosition ? <DropIndicator edge={dropPosition} /> : null}
225+
</div>
226+
)
227+
}
228+
```
229+
230+
```tsx
231+
<EditorProvider initialConfig={{schemaDefinition}}>
232+
<ListIndexProvider>
233+
<DndProvider>
234+
<PortableTextEditable />
235+
</DndProvider>
236+
</ListIndexProvider>
237+
<NodePlugin nodes={nodes} />
238+
</EditorProvider>
239+
```
240+
241+
Each plugin's README carries the full recipe, including a reference `DropIndicator` implementation.
242+
243+
### Route legacy render props into registrations
244+
245+
An existing editor typically renders every custom object through the same two callbacks: `renderBlock` for block objects and `renderChild` for inline objects, one generic implementation regardless of `_type`. `defineBlockObject` and `defineInlineObject` accept the catch-all `type: '*'` for exactly this shape of code: one registration matches every object type that has no more specific registration, so your generic renderers route straight through:
246+
247+
```tsx
248+
const nodes = [
249+
defineBlockObject({
250+
type: '*',
251+
render: ({attributes, children, node}) => (
252+
<div {...attributes}>
253+
{/* Whatever your `renderBlock` callback returned. */}
254+
<BlockObjectPreview node={node} />
255+
{children}
256+
</div>
257+
),
258+
}),
259+
defineInlineObject({
260+
type: '*',
261+
render: ({attributes, children, node}) => (
262+
<span {...attributes}>
263+
{/* Whatever your `renderChild` callback returned. */}
264+
<InlineObjectPreview node={node} />
265+
{children}
266+
</span>
267+
),
268+
}),
269+
]
270+
```
271+
272+
For text blocks, `type: 'block'` is all you need. The engine's legacy composition nests style innermost, list item around it, and the block wrapper outermost; reproduce that order in the registration:
273+
274+
```tsx
275+
const nodes = [
276+
defineTextBlock({
277+
type: 'block',
278+
render: ({attributes, children, node}) => {
279+
let content = children
280+
281+
if (node.style !== undefined) {
282+
// Whatever your `renderStyle` callback returned.
283+
content = <StyleWrapper style={node.style}>{content}</StyleWrapper>
284+
}
285+
286+
if (node.listItem !== undefined) {
287+
// Whatever your `renderListItem` callback returned.
288+
content = (
289+
<ListItemWrapper listItem={node.listItem} level={node.level ?? 1}>
290+
{content}
291+
</ListItemWrapper>
292+
)
293+
}
294+
295+
// Whatever your `renderBlock` callback wrapped around it all.
296+
return <div {...attributes}>{content}</div>
297+
},
298+
}),
299+
]
300+
```
301+
302+
The render props carry `node`, `path`, `focused`, and `selected`, so your existing components get the same information the legacy callbacks received. Once every kind you use is registered, the legacy props can come off `<PortableTextEditable>` entirely.
303+
304+
## Editing follows the sub-schema
305+
306+
Inside a container, the editor resolves the schema at the caret, not the top-level schema. A code block whose sub-schema declares no decorators won't accept bold, whether from the keyboard, the toolbar, or a paste.
307+
308+
Schema-aware plugins get the same gating for free, because their callbacks receive the sub-schema at the caret as `context.schema`. Take a schema whose code block forbids decorators, and a Markdown shortcut configured by schema lookup:
309+
310+
```tsx
311+
import {MarkdownShortcutsPlugin} from '@portabletext/plugin-markdown-shortcuts'
312+
313+
const schemaDefinition = defineSchema({
314+
decorators: [{name: 'strong'}, {name: 'em'}],
315+
blockObjects: [
316+
{
317+
name: 'code-block',
318+
fields: [
319+
{
320+
name: 'lines',
321+
type: 'array',
322+
// The code line allows a `code` style and no decorators.
323+
of: [{type: 'block', styles: [{name: 'code'}], decorators: []}],
324+
},
325+
],
326+
},
327+
],
328+
})
329+
```
330+
331+
```tsx
332+
<MarkdownShortcutsPlugin
333+
boldDecorator={({context}) =>
334+
context.schema.decorators.find((decorator) => decorator.name === 'strong')
335+
?.name
336+
}
337+
/>
338+
```
339+
340+
The callback runs against the schema at the caret. In a regular paragraph the lookup finds `strong`, so typing `**bold**` applies the decorator. Inside a code-block line the sub-schema declares `decorators: []`, the lookup returns `undefined`, and the shortcut skips itself. Nothing was configured per container: **the schema is the feature flag**, and the lookup is what reads it.
341+
342+
Your own code can resolve the schema at any position with `getPathSubSchema` from `@portabletext/editor/traversal`.
343+
344+
## Registration validation
345+
346+
A registration that doesn't match the schema is skipped with a `console.warn` naming the actual mismatch: an unknown type, a missing field, a field that isn't an array, or an array of primitives only. The editor keeps working; the type renders as an ordinary block object until the registration and schema agree.
347+
348+
## Containers in practice
349+
350+
[`@portabletext/plugin-table`](https://github.com/portabletext/editor/tree/main/packages/plugin-table) is built entirely on this API: three nested containers plus behaviors and UI. It's both a ready-made table editor and the reference for what containers can carry. The [Portable Text Playground](https://playground.portabletext.org/) ships container examples you can try, callouts, code blocks, fact boxes, and tables.

0 commit comments

Comments
 (0)