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.

Toolbar icons and span rendering

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.

Decorators

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:

Toolbar with custom decorator button

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:

Editor with custom render and icon for the highlight decorator

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.

Annotations

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 editor with a custom user icon for the internal link annotation

Custom component

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 with custom renderer for external links.

Block Styles

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:

The editor with a custom title block style

Custom paste handling

The editor is built to convert HTML, Word (with most of its weird edge cases!), and Google Docs content from the clipboard to Portable Text.

There are cases where you want more control over how content from the clipboard should be handled. Maybe you want to support some custom markup or convert certain HTML patterns to a custom content type. Customizing the paste handling can be a great way to let your editors structure legacy content by copy-pasting when scripting or automatization is not a feasible option.

Example: Custom pasting of code blocks in HTML

Perhaps you want to convert the <pre><code>/* some code */</code></pre> pattern in HTML into a custom content type for code in Portable Text so that you can query it in GROQ, and have more control over the frontend rendering. In order to augment the default Portable Text Editor, we'll define a custom input component using the components API. This API will let you define custom inputs that affect every portable text-field in your studio by adding your component to the root studio configuration under studio.components, or you can add your component directly to the schema of a specific portable text field.

The custom paste handler function is passed a ClipboardEvent with clipboardData. You can learn more about these on the MDN web docs.

In our example, we'll add the custom paste handler to a specific editor. We'll start by writing the paste handler function. Here's a bare-bones example that only logs out the pasted content and returns undefined, leaving it to the default configuration.

// CustomPortableTextEditor.js
import React from 'react'
import {BlockEditor} from 'sanity'

export function CustomEditor(props) {
  return (
    <BlockEditor {...props} onPaste={handlePaste} />
  )
}

function handlePaste(input) {
  const {event} = input
  const text = event.clipboardData.getData('text/plain')
  const json = event.clipboardData.getData('application/json')
  if (json) {
    console.log(json)
  }
  if (text) {
    console.log(text)
  }
  // return undefined to let the defaults do the work
  return undefined
}

To use this customized editor, import and pass it to inputComponent:

// content.js
import CustomEditor from './components/CustomPortableTextEditor'

export default {
  name: 'content',
  title: 'Content',
  type: 'array',
  inputComponent: CustomEditor,
  of: [
    {type: 'block'},
    {type: 'code'}
  ]
}

Now we want to deal with the incoming HTML. In order to convert HTML to Portable Text, we need a library called blockTools. It's already a dependency in the Studio so you don't need to install it. In the paste handler, we'll look for HTML content on the clipboard and if there is a code type in the schema. If these criteria are met, we'll pass the HTML content into the block tools method called htmlToBlocks. This method lets you pass in rules for how to deserialize HTML elements.

// CustomRichTextEditor.js
import React from 'react'
import { BlockEditor } from 'sanity'
import { htmlToBlocks } from '@sanity/block-tools'
export default class CommentBlockEditor extends React.PureComponent { render () { return ( <div> <BlockEditor {...this.props} onPaste={handlePaste} /> </div> ) } }
function handlePaste (input) {
const { event, type, path } = input
const html = event.clipboardData.getData('text/html')
// check if schema has the code type
const hasCodeType = type.of.map(({ name }) => name).includes('code')
if (!hasCodeType) {
console.log(
'Run `sanity install @sanity/code-input, and add `type: "code"` to your schema.'
)
}
if (html && hasCodeType) {
const blocks = htmlToBlocks(html, type, {
rules: [
{
deserialize (el, next, block) {
/**
* `el` and `next` is DOM Elements
* learn all about them:
* https://developer.mozilla.org/en-US/docs/Web/API/Element
**/
if (
!el ||
!el.children ||
(el.tagName && el.tagName.toLowerCase() !== 'pre')
) {
return undefined
}
const code = el.children[0]
const childNodes =
code && code.tagName.toLowerCase() === 'code'
? code.childNodes
: el.childNodes
let text = ''
childNodes.forEach(node => {
text += node.textContent
})
/**
* Return this as an own block (via block helper function),
* instead of appending it to a default block's children
*/
return block({
_type: 'code',
code: text
})
}
}
]
})
// return an insert patch
return { insert: blocks, path }
}
return undefined
}

Validation of annotations

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

A validation warning for the link annotation in the editor.

Margin Markers

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'}
  ]
}

Margin Actions

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

Was this article helpful?