Visual Editor /Visual editing /

Inline editor

Inline editing removes the need for content editors to understand the structure of the content by enabling them to click directly on elements in the preview to make changes.

Edit content inline

This guide introduces how inline editing works through HTML element annotations, along with common patterns for working these annotations. See the reference guide for further usage instruction.

# How inline editing works

Inline editing is made possible through annotating components. This involves adding one or more data attributes to help Visual Editor map content in the preview to the proper location in the proper content source, using the site's content schema.

export const ComposablePage = (props) => {
  return (
    <div data-sb-object-id={props.id}>
      <h1 data-sb-field-path="title">{props.title}</h1>
    </div>
  )
}
<div data-sb-object-id="1">
  <h1 data-sb-field-path="title">My Page Title</h1>
</div>

# Target generated HTML

It's important to note that Visual Editor works with the HTML code generated by your framework. It does not read the component code directly.

This means that you have to take care to understand how the HTML tree is built as a result of composing a page with framework-based components. More on this in common patterns below.

# Annotation types

There are two properties that make up an annotation:

  • Object ID: The uniquely-identifying value representing a document in a content source, typically defined using the data-sb-object-id attribute.
  • Field Path: A field name or path representing the identifying value of the field in the content source where the content should be stored, typically defined using the data-sb-field-path attribute.

These properties work in tandem to help Visual Editor determine how to store content changes by first locating the proper document in the proper source, and then finding the field within that document’s model.

See the reference for specific usage details

# Use the DOM tree

Annotations follow the structure of the DOM tree, setting the annotation scope for all descendants.

In the example above, the <h1> value was inferred to below to the document with ID 1 because the parent <div> set its scope.

In the example below, the heading field path is scoped to the document of ID matching the resolution of props.id, while body appears to have no scoped document and will prompt an error in the console.

// MyComponent.jsx
export const MyComponent = props => {
  return (
    <>
      <div data-sb-object-id={props.id}>
        {/* Scoped to `props.id` */}
        <h2 data-sb-field-path="heading">{props.heading}</h2>
      </div>
      {/* Causes an error because there appears to be no scope for this field path. */}
      <div
        data-sb-field-path="body"
        dangerouslySetInnerHTML={{ __html: props.body }}
      />
    </>
  );
};

Note

The body path "appears to have no scope" because Visual Editor reads the resulting HTML generated by your framework and not the component code itself.

If a ancestral component generated HTML with a data-sb-object-id attribute, then the body field path would be scoped to that object.

# Work with object IDs

Object ID annotations are how Visual Editor identifies the document to edit for the current element, along with akk descendants of that element in the DOM tree.

# Object ID exceptions

Most content sources make a document's uniquely-identifying value obvious. Content source modules use this value to represent the document ID whenever possible.

Exceptions to this are listed in the appropriate content source integration guide.

# Rescope with Object IDs

Because we can take advantage of the DOM tree while annotating, this means you can rescope objects within other objects.

Suppose you had a post document that referenced an author document, and you wanted to be able to edit the author's name inline. You can rescope the document by adding a new object ID annotation while within the context of another document.

export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <h1 data-sb-field-path="title">{props.post.title}</h1>
      <div data-sb-object-id={props.post.author.id}>
        <span data-sb-field-path="name">{props.post.author.name}</span>
      </div>
    </article>
  );
}

Note that title is within the content of the post, while name has been rescoped within the context of the author document.

# Work with field paths

Field path annotations are how Visual Editor knows how to map content within elements on the screen to fields in the content source.

# Map to model fields

Many websites contain components that do not have one-to-one parity to a data model. The properties for that component may not match the fields in the content schema, but may be transformed before being sent to the component as props.

However, field names specified with data-sb-field-path must match field names in the schema, and not another variables or property name used to render the value.

For example, assume you have a document in the content source with a structure like this:

{
  "id": "1",
  "type": "Post",
  "title": "Blog Post Title",
  "body": "<p>Hello World</p>"
}

And suppose you were using a component called <Markdown /> that expected you to send the markdown string as a property called content. If you hard-coded the field path, this would not work as expected.

export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <h1 data-sb-field-path="title">{props.post.title}</h1>
      <Markdown content={props.body} />
    </article>
  );
}
export function Markdown(props) {
  // Will not be editable unless the field name is `body` in the models
  // making use of this component.
  return (
    <div
      data-sb-field-path="body"
      dangerouslySetInnerHTML={{ __html: props.body }}
    />
  );
}

This doesn't work unless the field name for the property in the model is body. In this case, you might want to expose the annotation as a prop.

// components/Markdown.tsx
export function Markdown(props) {
  return (
    <div
      data-sb-field-path={props.annotation || "body"}
      dangerouslySetInnerHTML={{ __html: props.body }}
    />
  );
}

# Absolute vs relative field paths

In most cases with most content sources, an annotation consists of an ID and field name.

Some content sources allow for nesting content within a single document. (Git CMS is the typical example.) In this case, objects of some model may be nested within a document of another model. Consider this content structure:

{
  "id": "1",
  "type": "Post",
  "author": {
    "name": "Stephen King"
  }
}

In this case, author is an embedded object, represented by a model, with a field called name. The author object has no ID value, and can only be edited through the post object.

When annotating components, you can use dot notation to drill down into nested components. And when a field path begins with a dot (.), it is assumed to be chained to its most recent ancestor. Otherwise, it is assumed to be an absolute path.

The two examples below would make author.name editable inline.

export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <div data-sb-field-path="author">
        <span data-sb-field-path=".name">{props.post.author.name}</span>
      </div>
    </article>
  );
}
export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <span data-sb-field-path="author.name">{props.post.author.name}</span>
    </article>
  );
}

Note that this also works for arrays, where the index can follow a dot. Here's an example where a post had a list of authors:

export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <div data-sb-field-path="authors">
        {props.post.authors.map((author, index) => (
          <span data-sb-field-path={`.${index}.name`}>
            {props.post.author.name}
          </span>
        ))}
      </div>
    </article>
  );
}
<article data-sb-object-id="1">
  <div data-sb-field-path="authors">
    <span data-sb-field-path=".0.name">Stephen King</span>
    <span data-sb-field-path=".1.name">Dr. Seuss</span>
  </div>
</article>

# Combine ID with field paths

In situations where you don't want to add an element just to specify an ID, you can include the ID value in a field path annotation by separating it from the path with a colon (:). Here's the example above, written in a single line.

export function Post(props) {
  return (
    <div>
      <span data-sb-field-path={`${props.post.id}:author.name`}>
        {props.post.author.name}
      </span>
    </div>
  );
}

# XPaths for advanced patterns

In situations that require advanced controls over field paths, you can append an xpath to the field path following a hash (#). Here's an example that makes the title editable, even though there are multiple other children.

export function Post(props) {
  return (
    <div data-sb-field-path="title#text()[0]">
      {props.title}
      <span>...</span>
    </div>
  );
}

See more cases for xpath values in advanced patterns below.

# Common patterns

The following sections provide some instructions on the most frequently-used patterns when annotating components for inline editing.

# Annotate nested objects

Use dot notation to build a chained path to the proper field. Field paths that start with a dot are automatically chained to their closest ancestor. These two examples achieve the same result.

export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <div data-sb-field-path="author">
        <span data-sb-field-path=".name">{props.post.author.name}</span>
      </div>
    </article>
  );
}
export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <span data-sb-field-path="author.name">{props.post.author.name}</span>
    </article>
  );
}

# Annotate list fields

List fields should use the index as a node in the field path.

export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <div data-sb-field-path="authors">
        {props.post.authors.map((author, index) => (
          <span data-sb-field-path={`.${index}.name`}>
            {props.post.author.name}
          </span>
        ))}
      </div>
    </article>
  );
}
<article data-sb-object-id="1">
  <div data-sb-field-path="authors">
    <span data-sb-field-path=".0.name">Stephen King</span>
    <span data-sb-field-path=".1.name">Dr. Seuss</span>
  </div>
</article>

# Unable to use a parent element

When unable to use a parent element, the object ID value can be prefixed to the field path, separated by a colon (:).

export function Post(props) {
  return (
    <div>
      <span data-sb-field-path={`${props.post.id}:author.name`}>
        {props.post.author.name}
      </span>
    </div>
  );
}

# Nest fields within another object's scope

The context can be rescoped for an individual element by providing the ID with an absolute field path (as shown in the section above) or for all further descendants by annotating a new object ID.

Here's an example that rescopes all descendants of a div by specifying a new object ID.

export default function Post(props) {
  return (
    <article data-sb-object-id={props.post.id}>
      <h1 data-sb-field-path="title">{props.post.title}</h1>
      <div data-sb-object-id={props.post.author.id}>
        <span data-sb-field-path="name">{props.post.author.name}</span>
      </div>
    </article>
  );
}

# Advanced patterns

The practices shown above are enough to handle the majority of cases on most sites. The following cases occur with less frequency, but are essential for maintaining the flexibility to build the DOM how it best serves your project.

# Multiple fields as the same child

It's often convenient to use multiple properties within a single element, such as combining an author's first and last names.

export function MyComponent(props) {
  return (
    <div className="author" data-sb-field-path="first_name last_name">
      {author.first_name} {author.last_name}
    </div>
  );
}

It's much easier to work with structured content when each property get its own element. Use an inline element like <span> and annotate each individually.

export function MyComponent(props) {
  return (
    <div className="author">
      <span data-sb-field-path="first_name">{author.first_name}</span>
      <span data-sb-field-path="last_name">{author.last_name}</span>
    </div>
  );
}

# Elements with multiple children

When an element renders a field value as its direct text node child, but also includes other elements as children, use the xpath to specify the exact position of the rendered field value within the element.

export function MyComponent(props) {
  return (
    <div data-sb-field-path="title#text()[0]">
      {props.title}
      <span>...</span>
    </div>
  );
}

# Elements that render multiple fields

In some cases, an element can render two fields from the same object. You can specify both fields separated by a space, applying the proper xpath as necessary.

export function MyComponent(props) {
  return (
    <div data-sb-field-path="title#text()[0] subtitle#text()[2]">
      {props.title}
      <span>...</span>
      {props.subtitle}
    </div>
  );
}