Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion content/guides/01.data-model/3.interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,17 @@ The What You See Is What You Get (WYSIWYG) editor provides a text area with rich
| Folder | Default folder to store uploaded files. Does not affect existing files. |
| Soft Limit | Used to limit the number of characters within the Data Studio. |
| Static Access Token | Token appended to asset URLs when displaying in the editor. |
| Custom Formats | JSON array of custom formatting styles to add to the editor's formatting menu. Only inline formats are supported; block, selector, and wrapper formats are ignored. |
| Custom Formats | JSON array of custom formatting entries added to the editor's Formats menu. Supports inline, block, selector, and grouped entries. `wrapper` entries are not supported. See [Rich Text](/guides/data-model/rich-text#custom-formats). |
| Options Override | Deprecated and no longer applied. See the note below. |

::callout{icon="i-lucide-info"}
**The editor's engine changed in Directus 12.** The WYSIWYG interface is now built on [Tiptap](https://tiptap.dev). Existing fields keep working without migration, but stored HTML is normalized to the editor's supported markup on first edit, and the **Options Override** (`tinymceOverrides`) option is deprecated. See the [Version 12 breaking changes](/releases/breaking-changes/version-12#wysiwyg-editor-rebuilt-on-tiptap) for details.
::

::callout{icon="i-lucide-book-open" color="primary" to="/guides/data-model/rich-text"}
Configure custom formats, understand what the editor stores, and style the output on your frontend in the [Rich Text](/guides/data-model/rich-text) guide.
::

### Markdown

![A markdown text editor with a toolbar with formatting options. Edit and preview tabs.](/img/b41d6822-35ab-48db-ada9-dd3a723a5c52.webp)
Expand Down
307 changes: 307 additions & 0 deletions content/guides/01.data-model/4.rich-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
---
stableId: 2947492c-9b05-4261-b4dd-28ba4b0ceaba
title: Rich Text
description: Configure custom formats for the WYSIWYG interface, understand what the editor stores, and style the resulting HTML in your app's frontend CSS.
---

The [WYSIWYG interface](/guides/data-model/interfaces#wysiwyg) stores its value as HTML. This page covers the **Custom Formats** option, which adds your own formatting entries to the editor.

::callout{icon="i-lucide-info"}
The editor is built on [Tiptap](https://tiptap.dev) as of Directus 12. It stores only the markup its schema models. See [Version 12 breaking changes](/releases/breaking-changes/version-12#wysiwyg-editor-rebuilt-on-tiptap) for the supported HTML and how existing values are normalized.
::

## Custom Formats

**Custom Formats** takes a JSON array of formatting entries. Set it on the field's interface options. When the array is not empty, a **Formats** dropdown is added to the editor toolbar. There is no toolbar option to enable it.

The format is a subset of TinyMCE's `style_formats`, so existing configurations often carry over unchanged.

Every entry needs a `title` and one of the following keys:

| Key | Applies to |
| ---------- | --------------------------------------------------------------------------------------------- |
| `inline` | A tag wrapped around the selected text, such as `span`. |
| `block` | A single block tag. Converts the block when its tag differs. See [Block formats](#block-formats). |
| `selector` | One or more comma-separated block tags. Never converts the block. |
| `items` | An array of entries, grouped into a submenu. See [Grouped formats](#grouped-formats). |

An `inline`, `block`, or `selector` entry also needs at least one of `classes` or `attributes`, and can set both. The editor uses them to recognize the format again when the value is reloaded, so an entry carrying neither is skipped. An `items` group is a container and needs neither.

`styles` is the only optional key.

### Example

```json
[
{ "title": "Lead paragraph", "block": "p", "classes": "lead" },
{ "title": "Subheading", "selector": "h2,h3", "classes": "subheading" },
{
"title": "Callouts",
"items": [
{ "title": "Info", "block": "p", "classes": "callout callout-info" },
{ "title": "Warning", "block": "p", "classes": "callout callout-warning" }
]
},
{
"title": "Highlight",
"inline": "span",
"classes": "highlight",
"styles": { "background": "#ffff00" }
}
]
```

Applying **Lead paragraph** to a paragraph, then **Highlight** to the word `lead` inside it, turns:

```html
<p>A lead paragraph.</p>
```

into:

```html
<p class="lead">A <span class="highlight" style="background: #ffff00">lead</span> paragraph.</p>
```
Comment on lines +62 to +64

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe clearer if we give before and after here


Define `lead`, `subheading`, `callout`, and `highlight` in your frontend's CSS. Directus stores the classes but does not style them. See [Styling the output on your frontend](#styling-the-output-on-your-frontend).

### Inline formats

An `inline` entry renders a tag around the selection, carrying its `classes`, its `attributes`, and its `styles` serialized into a `style` attribute:

```json
{
"title": "Highlight",
"inline": "span",
"classes": "highlight",
"styles": { "background": "#ffff00" },
"attributes": { "title": "Highlighted text" }
}
```

Selecting the word `text` and applying the format turns:

```html
<p>Some text.</p>
```

into:

```html
<p>Some <span class="highlight" style="background: #ffff00" title="Highlighted text">text</span>.</p>
```
Comment on lines +84 to +92

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, before and after


Inline entries are the only formats that write a `style` attribute into the content, so they are also the only ones with a visible effect inside the editor. The trade-off is an inline style in your stored HTML, which is harder to override from your frontend CSS.

### Block formats

A `block` entry applies to one block tag and takes a single tag only. Use `selector` for a list.

```json
{ "title": "Lead paragraph", "block": "p", "classes": "lead" }
```

When the selected block's tag differs from the entry's tag, the block is converted. **Conversion works between paragraphs and headings only**, because those are the two block types that hold the same content.

With `{ "title": "Subheading", "block": "h2", "classes": "subheading" }`, applying the format to a paragraph turns:

```html
<p>Section title</p>
```

into:

```html
<h2 class="subheading">Section title</h2>
```

A `block: 'p'` entry converts the other way, turning a heading back into a paragraph.

Every other tag applies without converting. With `{ "title": "Pull quote", "block": "blockquote", "classes": "pull-quote" }`, applying the format to a block that is already a `<blockquote>` turns:

```html
<blockquote>A quote.</blockquote>
```

into:

```html
<blockquote class="pull-quote">A quote.</blockquote>
```

Applying it to a paragraph leaves the paragraph untouched, and the editor logs a warning in the browser console when it loads the format. Use `selector` when you never want conversion.

### Selector formats

A `selector` entry applies to the block tags you list and never changes the block's type:

```json
{ "title": "Subheading", "selector": "h2,h3", "classes": "subheading" }
```

Applying it to an `<h2>` or `<h3>` adds the class, turning:

```html
<h3>Section title</h3>
```

into:

```html
<h3 class="subheading">Section title</h3>
```

Applying it to a paragraph does nothing, because `p` is not in the entry's list:

```html
<p>Section title</p>
```

stays:

```html
<p>Section title</p>
```

### Grouped formats

An `items` array groups entries into a submenu in the **Formats** dropdown:

```json
{
"title": "Callouts",
"items": [
{ "title": "Info", "block": "p", "classes": "callout callout-info" },
{ "title": "Warning", "block": "p", "classes": "callout callout-warning" }
]
}
```

Groups support one level. A group nested inside another group is skipped, and a group whose entries are all invalid is dropped along with them.

### Which tags a block format can target

`block` and `selector` accept only tags the editor models as a node:

`p`, `h1` through `h6`, `pre`, `blockquote`, `section`, `article`, `figure`, `figcaption`, `details`, `summary`, `dl`, `dt`, `dd`, `hr`, `img`, `ul`, `ol`, `li`, `table`, `tr`, `td`, `th`, `video`, `audio`, `iframe`.

`div` and `span` are not on the list, so they cannot carry a block format. A `block: 'div'` entry is skipped. This matters when you migrate a TinyMCE configuration, since TinyMCE formats commonly wrap content in a `div`. It is the same reason a stored `<div class="float-left">` is unwrapped when the editor loads it. Use a `section`, `article`, or `figure` entry instead, or move the wrapper into your frontend template.

A `selector` entry drops the tags the editor does not model and keeps the rest, so `selector: 'h2,div,p'` still applies to `<h2>` and `<p>`. The entry is skipped only when none of its tags are modelled.

`selector` takes plain tag names. Compound CSS selectors such as `p.lead`, `div > p`, and `#main` are skipped rather than silently matching nothing.

### Invalid entries are skipped

An entry the editor cannot build is skipped, and the rest of the array still loads. Every skipped entry logs a warning to the **browser console**. Nothing appears in the interface, so check the console when a format does not show up in the dropdown.

Entries are skipped when they:

- Have no `title`.
- Have none of `inline`, `block`, `selector`, or `items`.
- Have no `classes` and no `attributes`, unless the entry is an `items` group.
- Use `wrapper`, which is not supported.
- Name only tags the editor does not model, such as `block: 'div'` or `selector: 'div,span'`.
- Pass a compound CSS selector such as `p.lead` or `#main` instead of a plain tag name.
- Pass a comma-separated list to `block` instead of `selector`.
- Nest a group inside another group.

## Gotchas

### The editor does not load your frontend CSS

::callout{icon="i-lucide-triangle-alert" color="warning"}
The editor is not a preview of your site. Classes applied through custom formats have no styling attached in the Data Studio. The exception is [inline formats](#inline-formats), which write a `style` attribute into the content and do render in the editor.
::

Directus loads no project stylesheet into the editing surface, so a class like `lead`, `float-left`, or `text-center` has no visual effect inside the WYSIWYG. The class is stored and round-trips correctly. It just has nothing styling it in the Data Studio.

This surprises authors, who apply a format, see no change, and assume it is broken. Point them at the **Formats** dropdown instead: it shows the active format in its label and highlights the matching row. That is the confirmation that a format applied, not the rendered content.

The editor's own content styles can also contradict your class. The editor styles `figure` as `display: table; margin: 0.8125rem auto`, so a captioned image with a `float-left` class renders centered in the editor and floated on your frontend. The same applies to anything depending on `display`, `float`, `width`, or a flex or grid context your frontend provides.

There is currently no option to inject custom CSS into the editor. The deprecated **Options Override** (`tinymceOverrides`) option is no longer applied, so TinyMCE's `content_css` has no equivalent.

Because of this, layout classes are the riskiest thing to hand to non-technical authors: no feedback in the editor, and a result that depends entirely on CSS the editor cannot see. Prefer semantic formats such as `lead`, `callout`, or `subheading` over layout ones where you can.

### `styles` on a block entry only styles the dropdown

On an `inline` entry, `styles` is written into the content as a `style` attribute. On a `block` or `selector` entry, `styles` only styles the entry's label inside the **Formats** dropdown. It is not written to the content.

The editor does not round-trip `style` on block nodes. Only `class`, `id`, `title`, `role`, `lang`, `dir`, `data-*`, and `aria-*` survive a save and load cycle. TinyMCE applied block `styles` to the block itself, so this is a behavior change to account for when migrating.

Use `classes` for block styling and define the CSS on your frontend.

### Attributes on a block entry are filtered

`attributes` on a `block` or `selector` entry accepts only `id`, `title`, `role`, `lang`, `dir`, `data-*`, and `aria-*`. Anything else, including `style` and `target`, is dropped with a console warning.

`attributes: { "class": "…" }` is merged into `classes`, so toggling the format off removes those classes too.

Inline entries do not filter `attributes`.

## Toggling behavior

Selecting an active format in the dropdown removes it. Applying and removing a block format behaves as follows:

- Removing a format strips only the classes and attributes that format configured. Unrelated classes, `id`, `data-*`, and `aria-*` on the same block survive.
- The block keeps its tag. A `block: 'h2'` format does not turn the heading back into a paragraph when you remove it.
- A format applies to every eligible block in the selection as a single undo step.
- A format counts as active when every eligible block in the selection carries all of its classes, or all of its attributes for a format anchored on attributes alone.

## Image captions

Adding or editing an image opens a drawer with a **Caption** field. A non-empty caption wraps the image in a `<figure>`:

```html
<figure>
<img src="https://example.com/assets/2b1a…" alt="A wind turbine" />
<figcaption>A wind turbine at dusk.</figcaption>
</figure>
```

Style `figure` and `figcaption` on your frontend to match. The editor centers them, which your own CSS will override.

Behavior worth knowing:

- Clearing the caption reverts to a bare `<img>`, but only when the `<figure>` holds nothing but images and carries no attributes of its own. A `<figure class="…">` you added deliberately is kept, and its caption is removed on its own.
- Editing an existing image updates it in place, so its attributes and its surrounding `<figure>` survive the edit.
- Pressing `Enter` inside a caption leaves the figure and starts a new paragraph after it, rather than adding a second `<figcaption>`.
- Pressing `Backspace` in an empty caption removes the caption.
- Deleting the image out of a figure removes the orphaned caption too.
- Stored `<figure>` and `<figcaption>` markup round-trips, including a caption placed before the image and a figure holding only a caption.

## Styling the output on your frontend

Directus stores the HTML. Rendering and styling it is your frontend's job.

Add the classes your formats configure to your stylesheet:

```css
.lead {
font-size: 1.25rem;
line-height: 1.6;
}

.subheading {
color: #6644ff;
text-transform: uppercase;
}

.callout {
border-left: 4px solid;
padding: 1rem;
}

.callout-info { border-color: #3399ff; }
.callout-warning { border-color: #ffaa00; }
```

Two things to keep in mind:

- Scope these styles so they do not collide with the rest of your site. Rendering the value inside a wrapper such as `.prose` and prefixing your selectors keeps author-applied classes from leaking.
- Review your selectors before upgrading to Directus 12 if you style stored HTML by tag, class, or attribute. Markup the editor does not model, such as `<div>` wrappers, is removed when a field is edited and saved.

## Next Steps

Read about the [WYSIWYG interface options](/guides/data-model/interfaces#wysiwyg), the [supported HTML and normalization behavior](/releases/breaking-changes/version-12#wysiwyg-editor-rebuilt-on-tiptap), and [keyboard shortcuts](/getting-started/accessibility) for the editor.
Loading