|
| 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