Sanity.io raises $9.3m Series A to redefine content management

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

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 the Sanity S. 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 show 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 with just the string H to blockEditor.icon:

// RichTextEditor.js
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',
blockEditor: {
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. If you do so, remember to add import React from 'react' in the file with the configuration. 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',
blockEditor: {
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 wrap them in a span with some styling.

// RichTextEditor.js
import React from 'react'

const highlightIcon = () => (
  <span style={{ fontWeight: 'bold' }}>H</span>
)
const highlightRender = 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', blockEditor: { icon: highlightIcon,
render: highlightRender
} } ] } } ] }

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 uses the react-icons library as a dependency. We'll use a paper clip icon for internal references.

// RichTextEditor.js
import { FaPaperclip } from 'react-icons/fa'
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',
blockEditor: {
icon: FaPaperclip
},
fields: [ { name: 'reference', type: 'reference', to: [ { type: 'post' } // other types you may want to link to ] } ] } ] } } ] }

Now the S-icon will be replaced with the paperclip:

The editor with a custom paperclip icon for the internal link annotation

Custom renderer

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.js and put it in /schemas/components (you are free to structure the files however you want).

// ExternalLinkRenderer.js
import React from 'react'
import PropTypes from 'prop-types'
import { FaExternalLinkAlt } from 'react-icons/fa'

const ExternalLinkRenderer = props => (
  <span>
    {props.children} <FaExternalLinkAlt />
  </span>
)

ExternalLinkRenderer.propTypes = {
  children: PropTypes.node.isRequired
}

export default ExternalLinkRenderer

Now we can import this component and pass it to blockEditor.render in our schema:

// RichTextEditor.js
import { FaPaperclip } from 'react-icons/fa'
import ExternalLinkRenderer from './components/ExternalLinkRenderer'
export default { name: 'content', title: 'Content', type: 'array', of: [ { type: 'block', marks: { annotations: [ { name: 'link', type: 'object', title: 'link',
blockEditor: {
render: ExternalLinkRenderer
},
fields: [ { name: 'url', type: 'url' } ] }, { name: 'internalLink', type: 'object', title: 'Internal link', blockEditor: { icon: FaPaperclip }, fields: [ { name: 'reference', type: 'reference', to: [ { type: 'post' } // 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 CSS modules. 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 blockEditor.render. The values for the block come in as a prop, and we can wrap it in the appropriate element with either inline styling, or using CSS Modules. 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',
blockEditor: {
render: 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. There are two ways of adding custom paste handling to Sanity’s studio. You can do it globally for all rich text editors by adding your paste handler function in the parts array of sanity.json, or you can pass it in as a prop for a specific rich text field in your project.

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

You are free to name and place your paste handler anywhere in your project, just make sure to get the path right:

{
  "implements": "part:@sanity/form-builder/input/block-editor/on-paste",
  "path": "./customization/onPaste.js"
}

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.

// CustomRichTextEditor.js
import React from 'react'
import {BlockEditor} from 'part:@sanity/form-builder'

export default class CustomEditor extends React.PureComponent {
  render() {
    return (
      <div>
        <BlockEditor {...this.props} onPaste={handlePaste} />
      </div>
    )
  }
}

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/customEditor'

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 'part:@sanity/form-builder'
import blockTools 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 = blockTools.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 to test 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 matches all the variatons 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
      {...this.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 besides 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'
import PropTypes from 'prop-types'

function MarginActions (props) {

  const handleClick = event => {
    const {insert} = props
    insert([{
      _type: 'block',
      children: [
        {
          _type: 'span',
          text: 'Pong!'
        }
      ]
    }])
  }
  return (
    <button type="button" onClick={this.handleClick}>Ping</button>
  )
}

MarginActions.propTypes = {
  block: PropTypes.shape({
    _key: PropTypes.string,
    _type: PropTypes.string
  }).isRequired,
  value: PropTypes.arrayOf(
    PropTypes.shape({
    _key: PropTypes.string,
    _type: PropTypes.string
    })
  ),
  path: PropTypes.string,
  insert: PropTypes.func,
  set: PropTypes.func,
  unset: PropTypes.func
}

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 'part:@sanity/form-builder'
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
      {...this.props}
      markers={allMarkers}
      renderCustomMarkers={renderCustomMarkers}
      renderBlockActions={marginActions}
    />
  )
}

export default ArticleBlockEditor

Was this article helpful?