Skip to content

Commit 77a88e3

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), nesting through `of` with the table example from `defineContainer`'s own jsdoc, how registrations opt into the new render pipeline (legacy block-level render props don't compose, span-level props keep working, and the consequences: list wrapping/numbering and drop-indicator chrome become the consumer's, with `plugin-list-index` and `plugin-dnd` as the remedies and a worked catch-all `defineTextBlock` example), editing semantics with 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), 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 77a88e3

2 files changed

Lines changed: 251 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: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
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:
147+
148+
- Your `render` owns the outer wrapper entirely. The block-level render props on `<PortableTextEditable>`, `renderBlock`, `renderStyle`, `renderListItem`, `renderChild`, do not compose for registered nodes, and the engine emits only `data-pt-*` attributes, no built-in wrapper markup.
149+
- Span-level rendering keeps working: `renderDecorator`, `renderAnnotation`, `renderPlaceholder`, and range decorations fire on the spans inside `children` regardless of who renders the block.
150+
- Text blocks are registered with `defineTextBlock`, `type: 'block'` for the top level, or the catch-all `type: '*'` to cover every text block at every nesting level. Non-editable void blocks (images, embeds) are registered with `defineBlockObject`. Containers cannot be registered by wildcard.
151+
152+
Owning the wrapper means owning things the engine used to do for you. The two that surprise people:
153+
154+
**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.
155+
156+
**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.
157+
158+
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:
159+
160+
```tsx
161+
import type {TextBlockRenderProps} from '@portabletext/editor'
162+
import {DndProvider, useDropPosition} from '@portabletext/plugin-dnd'
163+
import {ListIndexProvider, useListIndex} from '@portabletext/plugin-list-index'
164+
165+
const nodes = [
166+
defineTextBlock({
167+
type: '*',
168+
render: (props) => <TextBlock {...props} />,
169+
}),
170+
]
171+
172+
function TextBlock(props: TextBlockRenderProps) {
173+
// The 1-based position within the list, or `undefined` for a block
174+
// that is not a list item.
175+
const listIndex = useListIndex(props.path)
176+
// `'start' | 'end'` while a block drag hovers this block.
177+
const dropPosition = useDropPosition(props.path)
178+
179+
return (
180+
<div {...props.attributes} style={{position: 'relative'}}>
181+
{listIndex !== undefined ? (
182+
<span contentEditable={false}>{listIndex}. </span>
183+
) : null}
184+
{props.children}
185+
{dropPosition ? <DropIndicator edge={dropPosition} /> : null}
186+
</div>
187+
)
188+
}
189+
```
190+
191+
```tsx
192+
<EditorProvider initialConfig={{schemaDefinition}}>
193+
<ListIndexProvider>
194+
<DndProvider>
195+
<PortableTextEditable />
196+
</DndProvider>
197+
</ListIndexProvider>
198+
<NodePlugin nodes={nodes} />
199+
</EditorProvider>
200+
```
201+
202+
Each plugin's README carries the full recipe, including a reference `DropIndicator` implementation.
203+
204+
## Editing follows the sub-schema
205+
206+
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.
207+
208+
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:
209+
210+
```tsx
211+
import {MarkdownShortcutsPlugin} from '@portabletext/plugin-markdown-shortcuts'
212+
213+
const schemaDefinition = defineSchema({
214+
decorators: [{name: 'strong'}, {name: 'em'}],
215+
blockObjects: [
216+
{
217+
name: 'code-block',
218+
fields: [
219+
{
220+
name: 'lines',
221+
type: 'array',
222+
// The code line allows a `code` style and no decorators.
223+
of: [{type: 'block', styles: [{name: 'code'}], decorators: []}],
224+
},
225+
],
226+
},
227+
],
228+
})
229+
```
230+
231+
```tsx
232+
<MarkdownShortcutsPlugin
233+
boldDecorator={({context}) =>
234+
context.schema.decorators.find((decorator) => decorator.name === 'strong')
235+
?.name
236+
}
237+
/>
238+
```
239+
240+
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.
241+
242+
Your own code can resolve the schema at any position with `getPathSubSchema` from `@portabletext/editor/traversal`.
243+
244+
## Registration validation
245+
246+
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.
247+
248+
## Containers in practice
249+
250+
[`@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)