Skip to content
Draft
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
216 changes: 216 additions & 0 deletions packages/editor/RFC-renderer-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# RFC: Renderer Registration API for Portable Text Editor

## Summary

This RFC proposes a `registerRenderer` API that allows plugins to register custom rendering for PTE nodes, giving full control over DOM output while keeping existing render props as fallback defaults.

## Design Goals

- **Plugin-first**: Registered renderers take priority over prop-based render functions
- **Full DOM control**: Ability to customize or replace wrapper elements, attributes, drop indicators
- **Non-breaking**: Existing `renderBlock`, `renderChild`, etc. props continue to work as defaults
- **Consistent**: API mirrors `registerBehavior` pattern
- **Rendering-only**: Does NOT allow dynamic schema definition (schema stays in Sanity config)
- **Great TypeScript inference**: Schema generic + `name` enables typed `node` in render callback
- **No Slate exposure**: Renderers use PTE hooks (`useEditor`, `useEditorSelector`) for state

## Proposed API

```typescript
import {schema} from './schema'
import {useEditor, useEditorSelector} from '@portabletext/editor'

editor.registerRenderer<typeof schema>({
type: 'blockObject',
name: 'image',
render: ({attributes, children, node}) => (
<ImageRenderer attributes={attributes} node={node}>
{children}
</ImageRenderer>
),
})

// Standalone component uses hooks for state
function ImageRenderer({attributes, children, node}) {
const editor = useEditor()
const isReadOnly = useEditorSelector(editor, (s) => s.context.readOnly)
const selection = useEditorSelector(editor, (s) => s.context.selection)
const isFocused = /* derive from selection */

return (
<figure {...attributes} className={isFocused ? 'focused' : ''}>
<img src={node.url} alt={node.alt} />
{children}
</figure>
)
}

// Register via plugin component
<RendererPlugin renderers={[imageRenderer, videoRenderer]} />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this showing two different ways of registering them, e.g. editor.registerRenderer vs through plugin, or both would be required (e.g. register and enable)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<RendererPlugin /> would be a convenient plugin a la <BehaviorPlugin /> to abstract over editor.registerRenderer(...).

https://github.com/portabletext/editor/blob/main/packages/editor/src/plugins/plugin.behavior.tsx#L8

```

## Renderer Types

| Type | What it covers | Base node type | Replaces |
| -------------- | --------------------------- | ----------------------- | ---------------------------------------------- |
| `block` | Paragraphs, headings, lists | `PortableTextTextBlock` | `renderBlock`, `renderStyle`, `renderListItem` |
| `blockObject` | Images, embeds, etc. | `PortableTextObject` | `renderBlock` (for objects) |
| `inlineObject` | Mentions, emoji, etc. | `PortableTextObject` | `renderChild` (for objects) |
| `decorator` | Bold, italic, code | `string` | `renderDecorator` |
| `annotation` | Links, comments | `PortableTextObject` | `renderAnnotation` |

## Type-safe Rendering

| Property | Purpose |
| ----------------- | ----------------------------------------------------- |
| `<typeof schema>` | Generic for TypeScript type inference |
| `name` | Plucks the specific type AND runtime `_type` matching |

## Render Props

| Prop | Description |
| --------------- | ------------------------------------------ |
| `attributes` | Slate attributes to spread on root element |
| `children` | Slate children (required for DOM tracking) |
| `node` | The typed node being rendered |
| `renderDefault` | Compose with built-in rendering |
| `renderHidden` | Render minimal hidden DOM |

## Using Hooks for State

Renderers use PTE hooks for additional state (no Slate exposure):

```typescript
import {useEditor, useEditorSelector} from '@portabletext/editor'

function MyRenderer({attributes, children, node}) {
const editor = useEditor()
const isReadOnly = useEditorSelector(editor, (s) => s.context.readOnly)
const selection = useEditorSelector(editor, (s) => s.context.selection)
const decoratorState = useEditorSelector(editor, (s) => s.decoratorState)
const blockIndex = useEditorSelector(editor, (s) => s.blockIndexMap.get(node._key))

return <div {...attributes}>{children}</div>
}
```

## Examples

### blockObject - Standalone component

```typescript
function ImageBlock({attributes, children, node}: BlockObjectRenderProps<ImageBlock>) {
const editor = useEditor()
const isReadOnly = useEditorSelector(editor, (s) => s.context.readOnly)
const selection = useEditorSelector(editor, (s) => s.context.selection)
const isFocused = selection?.focus.path[0]?._key === node._key

if (isReadOnly && node.draft) {
return <HiddenBlock attributes={attributes}>{children}</HiddenBlock>
}

return (
<figure {...attributes} className={isFocused ? 'focused' : ''}>
<img src={node.url} alt={node.alt} />
{children}
</figure>
)
}

editor.registerRenderer<typeof schema>({
type: 'blockObject',
name: 'image',
render: (props) => <ImageBlock {...props} />,
})
```

### decorator - With decorator state

```typescript
function HighlightDecorator({children}: DecoratorRenderProps) {
const editor = useEditor()
const isBoldToo = useEditorSelector(editor, (s) => s.decoratorState.bold)

return <mark className={isBoldToo ? 'bold-highlight' : 'highlight'}>{children}</mark>
}

editor.registerRenderer<typeof schema>({
type: 'decorator',
name: 'highlight',
render: (props) => <HighlightDecorator {...props} />,
})
```

## Resolution Order

1. Registered renderers (by priority, highest first)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is priority defined? (order of registration? order in the renderer plugin? first defined executes first?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Order of registration and then the first defined wins. Like Behaviors. (Until we add a Priority API which we would want for Behaviors as well at one point)

2. Render props passed to `PortableTextEditable`
3. Built-in default renderers

## Key Files to Modify

- `packages/editor/src/editor/create-editor.ts` - Add `registerRenderer` method
- `packages/editor/src/editor/editor-machine.ts` - Store renderer configs in context
- `packages/editor/src/editor/components/render-*.tsx` - Integrate renderer lookup
- `packages/editor/src/types/editor.ts` - Add renderer type definitions
- New: `packages/editor/src/renderers/` - Renderer types, helpers, plugin component

## Design Decisions

### 1. Skipping/Hiding Rendering

Provide `renderHidden()` helper (returning `null` breaks Slate):

```tsx
render: ({renderHidden, node}) => {
if (node.draft) return renderHidden()
return <MyComponent />
}
```

### 2. Slate Attributes

Users spread `attributes` on root element (required by Slate):

```tsx
render: ({attributes, children}) => <div {...attributes}>{children}</div>
```

### 3. Default Render Composition

Use `renderDefault()` to wrap built-in rendering:

```tsx
render: ({renderDefault}) => <div className="wrapper">{renderDefault()}</div>
```

## Implementation Phases

### Phase 1: Core Infrastructure

- Define renderer types with schema generic + name-based type plucking
- Add renderer storage to editor actor context
- Implement `registerRenderer` method on editor instance

### Phase 2: Integration

- Modify render components to check for registered renderers
- Implement priority-based resolution
- Ensure Slate compatibility constraints

### Phase 3: Plugin Component

- Create `RendererPlugin` component (like `BehaviorPlugin`)
- Documentation and examples

### Phase 4: Testing

- Test with existing plugins
- Verify no breaking changes to render props
- Performance testing

## Open Questions

- Should we provide convenience hooks like `useNodeFocused(nodeKey)` and `useNodeSelected(nodeKey)`?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ohh, that would be neat!

- How should priority work when multiple renderers match the same type/name?
- Range decorations: keep current inline `component` approach or unify later?
4 changes: 4 additions & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
"source": "./src/behaviors/_exports/index.ts",
"default": "./lib/behaviors/index.js"
},
"./renderers": {
"source": "./src/renderers/_exports/index.ts",
"default": "./lib/renderers/index.js"
},
"./plugins": {
"source": "./src/plugins/_exports/index.ts",
"default": "./lib/plugins/index.js"
Expand Down
24 changes: 23 additions & 1 deletion packages/editor/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {EditorDom} from './editor/editor-dom'
import type {ExternalEditorEvent} from './editor/editor-machine'
import type {EditorSnapshot} from './editor/editor-snapshot'
import type {EditorEmittedEvent} from './editor/relay-machine'
import type {ArrayDefinition, ArraySchemaType} from './types/sanity-types'
import type {Renderer} from './renderers/renderer.types'

/**
* @public
Expand Down Expand Up @@ -50,6 +50,28 @@ export type Editor = {
* @beta
*/
registerBehavior: (config: {behavior: Behavior}) => () => void
/**
* @beta
* Register a custom renderer for a specific node type.
*
* @example
* ```tsx
* // Block object renderer (type: 'block', name matches the object type)
* const unregister = editor.registerRenderer({
* renderer: defineRenderer<typeof schema>()({
* type: 'block',
* name: 'image',
* render: ({attributes, children, node}) => (
* <figure {...attributes}>
* <img src={node.src} alt={node.alt} />
* {children}
* </figure>
* ),
* }),
* })
* ```
*/
registerRenderer: (config: {renderer: Renderer}) => () => void
send: (event: EditorEvent) => void
on: ActorRef<Snapshot<unknown>, EventObject, EditorEmittedEvent>['on']
}
29 changes: 27 additions & 2 deletions packages/editor/src/editor/create-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import {debugWithName} from '../internal-utils/debug'
import {compileType} from '../internal-utils/schema'
import {corePriority} from '../priority/priority.core'
import {createEditorPriority} from '../priority/priority.types'
import type {EditableAPI} from '../types/editor'
import type {PortableTextSlateEditor} from '../types/slate-editor'
import type {Renderer, RendererConfig} from '../renderers/renderer.types'
import type {EditableAPI, PortableTextSlateEditor} from '../types/editor'
import {defaultKeyGenerator} from '../utils/key-generator'
import {createEditableAPI} from './create-editable-api'
import {createSlateEditor, type SlateEditor} from './create-slate-editor'
Expand Down Expand Up @@ -99,6 +99,31 @@ export function createInternalEditor(config: EditorConfig): {
})
}
},
registerRenderer: (rendererConfig: {renderer: Renderer}) => {
const priority = createEditorPriority({
name: 'custom',
reference: {
priority: corePriority,
importance: 'higher',
},
})
const rendererConfigWithPriority = {
...rendererConfig,
priority,
} satisfies RendererConfig

editorActor.send({
type: 'add renderer',
rendererConfig: rendererConfigWithPriority,
})

return () => {
editorActor.send({
type: 'remove renderer',
rendererConfig: rendererConfigWithPriority,
})
}
},
send: (event) => {
switch (event.type) {
case 'update value':
Expand Down
Loading
Loading