Customizing the Portable Text Editor
How to customize the editor for Portable Text
Sanity’s editor for Portable Text is built to be customizable for different editorial needs. You can configure and tailor several different editors throughout the studio. To learn more about how to configure the editor in this article.
Most of the customizations are done by passing React components into the schema definitions for content types using Portable Text. They will also accept simple strings.
When you configure custom markers, that is, decorators (simple values) and annotations (rich data structures) they will appear as icon affordances in the toolbar. Out of the box, the icon will be rendered as a question mark. Chances are that you want to change this.
If you add custom decorators and annotations there's also a good chance you want to control their visual representations in the editor. By default, decorators will be invisible, and annotations will be shown with a grey background and a dotted underline.
Some often-used decorators (like strong, emphasis, and code
) come with rendering out of the box. Let’s say you have made a decorator for highlighting using this configuration:
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
{ title: 'Highlight', value: 'highlight' }
]
}
}
]
}
Now, let's add a custom toolbar icon by passing in an anonymous function which just returns the string H
to .icon:
// RichTextEditor.jsx
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
{
title: 'Highlight',
value: 'highlight',
icon: () => 'H'
}
]
}
}
]
}
The string is rendered in the decorator button in the toolbar:
You can also pass in a JSX component directly in the schema, or via an import. Here we have just set some simple inline styling to a span with an “H”.
// RichTextEditor.js
import React from 'react'
const HighlightIcon = () => (
<span style={{fontWeight: 'bold'}}>H</span>
)
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
{
title: 'Highlight',
value: 'highlight',
icon: HighlightIcon
}
]
}
}
]
}
The next step is to render the actual highlighted text in the editor. We do this by passing the props into a React component and wrapping them in a span with some styling.
// RichTextEditor.js
import React from 'react'
const HighlightIcon = () => (
<span style={{ fontWeight: 'bold' }}>H</span>
)
const HighlightDecorator = props => (
<span style={{ backgroundColor: 'yellow' }}>{props.children}</span>
)
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
marks: {
decorators: [
{ title: 'Strong', value: 'strong' },
{ title: 'Emphasis', value: 'em' },
{ title: 'Code', value: 'code' },
{
title: 'Highlight',
value: 'highlight',
icon: HighlightIcon,
component: HighlightDecorator
}
]
}
}
]
}
In the editor we will now have a yellow background for highlighted text:
Protip
Sometimes you would like to keep all, or some, of the built-in decorators when adding your own ones. The built-in decorators are the following:
{ "title": "Strong", "value": "strong" },
{ "title": "Emphasis", "value": "em" },
{ "title": "Code", "value": "code" },
{ "title": "Underline", "value": "underline" },
{ "title": "Strike", "value": "strike-through" }
Make sure to include the ones you would like to keep.
Customizing annotations works much in the same way as decorations. You can pass in an icon and a renderer in the schema definition. A common use case is to have an annotation for an internal reference, in addition to a link with an external URL. Let's customize the editor so that we use a custom icon for the internal link and a renderer that shows inline in the text which of the links are external.
The example below imports an icon from the @sanity/icons
-package. We'll use a user icon for internal references to a person
type.
// RichTextEditor.js
import { UserIcon } from '@sanity/icons'
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
marks: {
annotations: [
{
name: 'link',
type: 'object',
title: 'link',
fields: [
{
name: 'url',
type: 'url'
}
]
},
{
name: 'internalLink',
type: 'object',
title: 'Internal link',
icon: UserIcon,
fields: [
{
name: 'reference',
type: 'reference',
to: [
{ type: 'person' }
// other types you may want to link to
]
}
]
}
]
}
}
]
}
Now the question mark in your toolbar will be replaced with the user icon:
The next step is to make a custom renderer for external links, appending an "arrow out of a box" icon to those marks. We'll do this by passing in a small React component. Let's make a file called ExternalLinkRenderer.tsx
and put it in /schemas/components
(you are free to structure the files however you want).
// ExternalLinkRenderer.js
import React from 'react'
import { LaunchIcon } from '@sanity/icons'
const ExternalLinkRenderer = props => (
<span>
{props.renderDefault(props)}
<a contentEditable={false} href={props.value.href}>
<LaunchIcon />
</a>
</span>
)
export default ExternalLinkRenderer
Now we can import this component and pass it to components.annotation
in our schema:
// RichTextEditor.js
import { UserIcon } from '@sanity/icons'
import ExternalLinkRenderer from './components/ExternalLinkRenderer'
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
marks: {
annotations: [
{
name: 'link',
type: 'object',
title: 'link',
fields: [
{
name: 'url',
type: 'url'
}
],
components: {
annotation: ExternalLinkRenderer
}
},
{
name: 'internalLink',
type: 'object',
title: 'Internal link',
icon: UserIcon
fields: [
{
name: 'reference',
type: 'reference',
to: [
{ type: 'person' }
// other types you may want to link to
]
}
]
}
]
}
}
]
}
Now external links will look like this:
The editor for Portable Text comes with a set of styles that translate well to those you'll find in HTML. Your front end might not be targeting HTML though. While it has always been possible to add and configure block styles, you can now also configure how these styles render in the editor using markup and the styling method of your choice. This means you can tune your editor to be aligned with your organization’s design system.
To illustrate this, let's make a custom title style using Garamond as the font-face and a slightly increased font size. We begin with defining a custom style called title
:
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
styles: [
{title: 'Normal', value: 'normal'},
{title: 'Title', value: 'title'},
{title: 'H1', value: 'h1'},
{title: 'H2', value: 'h2'},
{title: 'H3', value: 'h3'},
{title: 'Quote', value: 'blockquote'},
]
}
]
}
Without any customization, the block will just look the same as the normal
. In order to style it, we'll need to make a renderer in React. It works pretty much the same way as renderers for marks: We pass in a React component to component
. The values for the block come in as a prop, and we can wrap it in the appropriate element with appropriate styling. In this example, we'll add the React component to the configuration file to illustrate how it's done.
// RichTextEditor.js
import React from 'react'
const TitleStyle = props => (
<span style={{fontFamily: 'Garamond', fontSize: '2em'}}>{props.children} </span>
)
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
styles: [
{title: 'Normal', value: 'normal'},
{title: 'H1', value: 'h1'},
{title: 'H2', value: 'h2'},
{title: 'H3', value: 'h3'},
{title: 'Quote', value: 'blockquote'},
{
title: 'Title',
value: 'title',
component: TitleStyle
},
]
}
]
}
Now the title block will render with the customized style in the editor:
You can also add content validation to annotations as with other content types. Warnings will appear in the margin, and in the document with a pointer that activates the annotation modal for the editor. Validations can be useful to help editors structure the content correctly. It's generally considered a good idea to involve editors in creating validations and testing the warning messages so that they are helpful.
Let's say that you are using the same content for multiple websites. Then it's especially important that internal linking is done by using an annotation with a reference
input. Both to prevent accidental deletion of content that is linked to, but also to be able to resolve internal links in the frontend project. You can create a simple validation that takes care of this:
export default {
name: 'content',
title: 'Content',
type: 'array',
of: [
{
type: 'block',
validation: Rule => Rule.regex(/.*damnation.*/gi, { name: 'profanity' }),
marks: {
annotations: [
{
name: 'link',
type: 'object',
title: 'link',
fields: [
{
name: 'url',
type: 'url',
validation: Rule =>
Rule.regex(
/https:\/\/(www\.|)(portabletext\.org|sanity\.io)\/.*/gi,
{
name: 'internal url',
invert: true
}
).warning(
`This is not an external link. Consider using internal links instead.`
)
}
]
},
{
name: 'internalLink',
type: 'object',
title: 'Internal link',
fields: [
{
name: 'reference',
type: 'reference',
to: [
{ type: 'post' }
// other types you may want to link to
]
}
]
}
]
}
}
]
}
The regular expression /https:\/\/(www\.|)(portabletext\.org|sanity\.io)\/.*/i
triggers on all URLs that match all the variations of either portabletext.org
or sanity.io
with some sub-paths (because we want to allow linking to the root domain).
In addition to validation markers, you can also define your own markers that will display in the right margin. These must be added to the editor's input prop called markers
. They are objects like this:
{
type: 'comment',
path: [{_key: 'theBlockKey'}],
item: {
_type: 'comment',
_key: '2f323432r23',
body: 'I am commenting this block!'
}
}
In order to show a custom marker you must wrap the editor component and create a function that renders your custom markers the way you want, and returns a React node (or null
):
// renderCustomMarkers.js
import React from 'react'
export default function renderCustomMarkers(markers) {
return (
<div>
{markers.map((marker, index) => {
if (marker.type === 'comment') {
return <div key={`marker${index}`}>A comment!</div>
}
return null
})}
</div>
)
}
Then create a wrapper for the editor input which gives the editor the prop renderCustomMarker
:
// CustomRichTextEditor.js
import React from 'react'
import {BlockEditor} from 'part:@sanity/form-builder'
import renderCustomMarkers from './renderCustomMarkers' // From above example
function ArticleBlockEditor (props) {
const {value, markers} = props
const customMarkers = [
{type: 'comment', path: value && value[0] ? [{_key: value[0]._key}] : [], value: 'This must be written better!'}
]
const allMarkers = markers.concat(customMarkers) // [...markers, ...customMarkers] works too
return (
<BlockEditor
{...props}
markers={allMarkers}
renderCustomMarkers={renderCustomMarkers}
/>
)
}
export default ArticleBlockEditor
Finally, tell the schema to use your wrapper component as inputComponent
:
// content.js
import CustomRichTextEditor from './CustomRichTextEditor.js'
export default {
title: 'Content',
name: 'content',
type: 'array',
inputComponent: CustomRichTextEditor,
of: [
{type: 'block'}
]
}
You can also define block actions for the blocks, which also renders in the right margin beside markers. These are typical buttons that will perform some action on the related block. The pattern is the same as on the custom markers above, but the prop key is renderBlockActions
. This will be a function that gets the block as the input, and then returns a React node (or null) for that block.
Here’s an example of a margin action that renders a button that inserts a new block with a span that contains the text ”Pong!”:
// marginActions.js
import React from 'react'
function MarginActions (props) {
const handleClick = event => {
const {insert} = props
insert([{
_type: 'block',
children: [
{
_type: 'span',
text: 'Pong!'
}
]
}])
}
return (
<button type="button" onClick={handleClick}>Ping</button>
)
}
export default MarginActions
And to include this action in the custom rich text editor we need to import it and add it as a property:
// CustomRichTextEditor.js
import React from 'react'
import {BlockEditor} from 'sanity'
import renderCustomMarkers from './renderCustomMarkers'
import marginActions from './marginActions.js'
function ArticleBlockEditor (props) {
const {value, markers = []} = props
const customMarkers = [
{type: 'comment', path: value && value[0] ? [{_key: value[0]._key}] : [], value: 'Rephrase this section for clarity!'}
]
const allMarkers = markers.concat(customMarkers) // [...markers, ...customMarkers] works too
return (
<BlockEditor
{...props}
markers={allMarkers}
renderCustomMarkers={renderCustomMarkers}
renderBlockActions={marginActions}
/>
)
}
export default ArticleBlockEditor