Common Portable Text Editor patterns
This series of practical examples shows how to customize block content and the Portable Text Editor in Sanity Studio.
Custom decorators
To render content the way you want it to be shown, you can create custom decorators.
import {defineArrayMember} from 'sanity'
defineArrayMember({
type: 'block',
marks: {
decorators: [
{
title: 'Highlight',
value: 'highlight',
component: (props) => (
<span style={{backgroundColor: '#0f0'}}>
{props.children}
</span>
),
icon: BulbOutlineIcon,
},
],
},
})The code example defines a member of an array that enables creating a block with decorators. The decorator in the example has props, such as value, component, and icon; these props define how the block is rendered:
valueapplies ahighlightdecorator to the component.componentis a React element. It has the same props asBlockDecoratorProps. It renders the child props wrapped in aspanelement with a green background color (#0f0).iconis an instance ofBulbOutlineIcon.
Custom styles
You can create custom decorators to render custom styles. Custom styles extend the standard set of styles available out of the box.
Gotcha
- A rendered preview isn't available for custom styles. It's possible to preview only the default built-in styles available in the editor toolbar menu.
- Custom styles don't support the
iconprop.
import {defineArrayMember} from 'sanity'
import {Card, Text} from '@sanity/ui'
defineArrayMember({
type: 'block',
styles: [
{
title: 'Section Header',
value: 'sectionHeader',
component: (props) => (
<Card paddingBottom={4}>
<Text size={4} weight="bold">
{props.children}
</Text>
</Card>
),
},
],
})The code example defines a block array member and adds style options to it:
- The style is
sectionHeader. - The child props of the component in the block are rendered as a card with bold text.
Spellchecking
You can enable and disable the web browser's built-in spell-checker for text blocks. To do so, set options.spellCheck to either true or false for the specified block type.
defineArrayMember({
type: 'block',
options: {
spellCheck: false,
},
})The code example defines a block array member, and it disables spellchecking text in the block.
Customizing block content rendering
You can render block content in Sanity Studio using one of the following form components:
block: renders any valid Portable Text block (text or object.)inlineBlock: renders a Portable Text block inline inside a running piece of text.annotation: renders text with annotated metadata (for example, a URL link to reference an external resource, or a cross-reference to another document.)
You can modify specific schema types to customize only the corresponding components. Alternatively, you can modify the studio config or create a plugin to apply the customization to all block content in Sanity Studio.
Customizing specific block content
To customize a specific block content type, use the components property associated with that type.
Define a block to provide your custom render component for the associated content type.
The following example customizes the rendering of text and image blocks in the body field.
import {Box} from '@sanity/ui'
import {defineField, defineArrayMember} from 'sanity'
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
defineArrayMember({
type: 'image',
// Replace the preview of all block images
// with the edit form for that image, bypassing
// the modal step.
components: {
block: (props) => {
return props.renderDefault({
...props,
renderPreview: () => props.children,
})
},
},
}),
defineArrayMember({
type: 'block',
// Add extra padding to all text blocks
// for this type.
components: {
block: (props) => {
return (
<Box padding={2}>
{props.renderDefault(props)}
</Box>
)
},
},
}),
],
})The defineField function creates a field called body, which is an array of two types of content: images and blocks.
The components property enables customizing the behavior of the field.
The block function is a component that renders the preview of a text or an image block. It takes props as an argument, and it returns a rendered version of the content with additional styling:
- It bypasses the modal step when previewing images.
- It adds extra padding to the text blocks.
Customizing block content with the studio config
To customize the default rendering of all block content in Sanity Studio, modify the studio config, instead of customizing schemas as shown in the previous section.
The following example reuses the customization described in the previous example, but it sets it in the studio config, instead of the schema type. The studio config applies the customization to any text block or image type rendered as block content.
import {definePlugin, defineField, BlockProps} from 'sanity'
const BlockComponent = (props: BlockProps) => {
// Add extra padding to all text blocks
if (props.schemaType.name === 'block') {
return (
<Box padding={2}>
{props.renderDefault(props)}
</Box>
)
}
// Inline editing of images
if (props.schemaType.name === 'image') {
return props.renderDefault({
...props,
renderPreview: () => props.children,
})
}
// Render default for all other types
return props.renderDefault(props)
}
// The config in sanity.config.ts
definePlugin({
...,
form: {
components: {
block: BlockComponent,
},
},
})
// This schema gets the customizations automatically
// added to the 'block' and 'image' types.
defineField({
name: 'intro',
title: 'Intro',
type: 'array',
of: [
{type: 'block'},
{type: 'image'},
]
})In the code example:
BlockComponenttakespropsand returns a component that does the following:- Adds extra padding for all text blocks
- Enables skipping the preview and directly editing images inline
- Applies the default rendering to all other types.
- The
defineFieldfunction defines a schema field that automatically adds the customizations to theblockandimagetypes.
Customizing block content with a plugin
Instead of modifying the studio config, you can use the code in the previous example to create a plugin to achieve the same outcome.
The advantage is that you can install and share the plugin across multiple studios and workspaces.
For more information about creating plugins, see Developing plugins.
Customizing the input
Besides customizing block content, you can also customize PortableTextInput to change editing block content in Sanity Studio.
This option enables rendering additional information, such as a word counter or supporting custom hotkeys to control editing features.
The following example shows a simple implementation where you can modify the input by assigning custom values to the props of PortableTextInput.
import {
defineField,
defineArrayMember,
PortableTextInput,
PortableTextInputProps,
} from 'sanity'
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
{
type: 'block',
},
],
components: {
input: (props: PortableTextInputProps) => {
return props.renderDefault(props)
// Alternatively:
// return <PortableTextInput {...props} />
},
},
})Replace the input form component with your custom component. To do so, use either a block content schema type definition, or definePlugin in the studio config.
Custom hotkeys
You can also set custom hotkeys by passing your hotkey mapping as hotkeys props to PortableTextInput.
The following example implements two hotkeys:
- A hotkey for a custom highlight decorator.
- Another hotkey to enable adding a link annotation to the selected text:
import {useMemo} from 'react'
import {
defineField,
defineArrayMember,
PortableTextEditor,
PortableTextInput,
PortableTextInputProps,
} from 'sanity'
// The custom input with two custom hotkeys
const CustomInput =
(props: PortableTextInputProps) => {
const {path, onItemOpen, onPathFocus} = props
// Define the hotkey mapping
const hotkeys: PortableTextInputProps['hotkeys'] = useMemo(
() => ({
// Use the 'marks' prop to toggle
// text decorators on the currently
// selected text with a hotkey
marks: {
'Ctrl+h': 'highlight',
},
// Use the 'custom' prop to define custom
// functions that can access the underlying
// editor instance.
// In this case, the 'Ctrl+l' hotkey toggles
// a link on the selected text using the
// PortableTextEditor API with the editor instance.
custom: {
'Ctrl+l': (event, portableTextEditor) => {
const linkType = portableTextEditor.
schemaTypes.annotations.find((a) => a.name === 'link')
if (linkType) {
event.preventDefault()
const activeAnnotations =
PortableTextEditor.activeAnnotations(portableTextEditor)
const isLinkActive =
activeAnnotations.some((a) => a._type === 'link')
if (isLinkActive) {
PortableTextEditor.removeAnnotation(
portableTextEditor,
linkType
)
} else {
const result = PortableTextEditor.addAnnotation(
portableTextEditor,
linkType
)
if (result?.markDefPath) {
// Open the form member
onItemOpen(path.concat(result.markDefPath))
// Move the focus to the 'href' field in the next tick
setTimeout(() => {
onPathFocus(result.markDefPath.concat('href'))
})
}
}
}
},
},
}),
[onPathFocus, onItemOpen, path],
)
return <PortableTextInput {...props} hotkeys={hotkeys} />
}
// The schema type to use for the custom input above
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
defineArrayMember({
type: 'block',
marks: {
decorators: [
{
title: 'Highlight',
value: 'highlight',
component: (props) => (
<span style={{backgroundColor: '#0f0'}}>
{props.children}
</span>
),
icon: BulbOutlineIcon,
},
],
},
}),
],
components: {
// Return the custom input defined above
input: CustomInput,
},
})The example defines a custom input component for the Portable Text editor.
The hotkey mapping includes a Ctrl+h key combination to toggle highlighting on the current selected text, and a Ctrl+l key combination to toggle a link on the selected text.
The marks property defines hotkey mappings for text decorators, whereas the custom property defines custom functions that can access the underlying editor instance.
In the example, custom adds a link annotation to PortableTextEditor:
- It checks if the link type is an existing schema type.
- If the link type exists, it prevents the default action that
Ctrl+lwould trigger, and it adds an annotation for the link type. - It checks if an active link annotation exists, and it either adds or removes it accordingly.
- If adding or removing the annotation returns a result, it opens the form member and moves the focus to the
hreffield.
Custom paste handler
The following example implements custom paste handling for any clipboard text that is a valid URL.
It pastes the content as a resource type inline block at the current cursor position.
import {
defineField,
defineArrayMember,
PortableTextInput,
PortableTextInputProps,
} from 'sanity'
// The custom paste handler function to pass as props
// to PortableTextInput
const onPaste: PortableTextInputProps['onPaste'] = (data) => {
let url: URL
const text =
data.event.clipboardData.getData('text/plain') || ''
// Check if clipboard data is a URL
try {
url = new URL(text)
// Insert an inline resource object in the text
return Promise.resolve({
insert: [
{
_type: 'block',
children: [{_type: 'resource', url: url.href}],
},
],
// To set a specific location to insert
// the pasted content, instead of the current
// cursor position, define a 'path' prop
})
} catch (_) {
return undefined
}
}
// The block content schema type to use
// for the custom paste handler above
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [
defineArrayMember({
type: 'block',
of: [
{
type: 'object',
name: 'resource',
title: 'Resource',
fields: [{type: 'url', name: 'url'}],
},
],
}),
],
components: {
input: (props) => {
return <PortableTextInput {...props} onPaste={onPaste} />
},
},
})Custom block validation
The following example validates a text block: it checks for a set of disallowed content using regex matching on every span node of the text block.
To test the example, type "foo" inside a text block.
import {
Path,
PortableTextSpan,
defineArrayMember,
defineType,
isPortableTextSpan,
isPortableTextTextBlock,
} from 'sanity'
interface DisallowListLocation {
matchText: string
message: string
offset: number
path: Path
span: PortableTextSpan
level: 'error' | 'info' | 'warning'
}
export default defineType({
name: 'customValidationExample',
title: 'Custom block validation example',
type: 'document',
fields: [
{
name: 'blockContent',
title: 'Block content with custom validation',
type: 'array',
of: [
defineArrayMember({
type: 'block',
validation: (Rule) => [
Rule.error().custom((value, context) => {
const disallowList: {regExp: RegExp; message: string}[] = [
{
message: 'Use n-dash (–) instead',
regExp: new RegExp(/^- /g),
},
{
message: 'Use a bullet list instead',
regExp: new RegExp(/^\* /g),
},
{
message: 'Avoid using \'foo\'',
regExp: new RegExp(/\bfoo\b/g),
},
]
const {path} = context
const locations: DisallowListLocation[] = []
if (path && isPortableTextTextBlock(value)) {
value.children.forEach((child) => {
if (isPortableTextSpan(child)) {
disallowList.forEach((entry) => {
const matches = isPortableTextSpan(child) && child.text.matchAll(entry.regExp)
if (matches) {
Array.from(matches).forEach((match) => {
locations.push({
span: child,
matchText: match[0],
path: path.concat(['children', {_key: child._key}]),
offset: match.index || 0,
message: entry.message,
level: 'error',
})
})
}
})
}
})
}
if (locations.length) {
return {
message: `${locations.map((item) => item.message).join('. ')}.`,
}
}
return true
}),
],
}),
],
},
],
})In the code example, onPaste defines a custom paste handler for the PortableTextInput component.
- It checks if the clipboard data is a URL; if so, it inserts the URL as an inline resource object in the text block.
- The default insert location is the current cursor position. It's also possible to assign a different insert location by setting an optional
pathprop.
Was this page helpful?