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
1 change: 1 addition & 0 deletions content/guides/01.data-model/3.interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ The What You See Is What You Get (WYSIWYG) editor provides a text area with rich
| 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 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). |
| Extensions | [Rich text extensions](/guides/extensions/app-extensions/richtext) this field can use. Shown only when at least one is installed. Markup from unselected extensions is removed on save. |
| Options Override | Deprecated and no longer applied. See the note below. |

::callout{icon="i-lucide-info"}
Expand Down
274 changes: 273 additions & 1 deletion content/guides/09.extensions/3.app-extensions/7.richtext.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,278 @@ navigation:

Rich text extensions add [Tiptap](https://tiptap.dev) nodes, marks, and toolbar buttons to the [WYSIWYG interface](/guides/data-model/interfaces#wysiwyg). A field uses an extension only when you select it in the interface's **Extensions** option.

You write nodes and marks with the standard Tiptap API. Directus adds a small config around them, and a set of rules that keep content intact when users save, compare, or remove an extension.

:partial{content="extensions-app"}

## Before You Start

- You know how to write a Tiptap node or mark with `parseHTML` and `renderHTML`.
- You have a Directus project to test in. Set `EXTENSIONS_AUTO_RELOAD=true` while you develop.

## Create an Extension

Run the extensions CLI and select the `richtext` type:

```shell
npx create-directus-extension@latest
```

The template contains one mark and one toolbar button. It adds `@tiptap/core` as a dev dependency so your editor can type-check Tiptap imports. The build does not bundle it. See [Shared Tiptap Packages](#shared-tiptap-packages).

The generated `package.json` declares the extension type in `directus:extension`. Before you publish, set its `host` field as described in [Set the Host Range](#set-the-host-range).

Rich text extensions load in the Data Studio only. You can also add one to a [bundle](/guides/extensions/bundles) as a `richtext` entry.

## Rich Text Entrypoint

The `index.js` or `index.ts` file exports the config. It lists the Tiptap extensions to add to the editor schema, and the buttons that act on them.

### Entrypoint Example

This example adds a note block and a toolbar button that wraps the selection in it.

```js
import { defineRichText } from '@directus/extensions-sdk';
import { Node, mergeAttributes } from '@tiptap/core';

const Note = Node.create({
name: 'note',
group: 'block',
content: 'block+',
parseHTML: () => [{ tag: 'div[data-note]' }],
renderHTML: ({ HTMLAttributes }) => ['div', mergeAttributes(HTMLAttributes, { 'data-note': '' }), 0],
});

export default defineRichText({
id: 'note',
name: 'Note',
extensions: [Note],
buttons: [
{
key: 'note',
icon: 'sticky_note_2',
label: 'Note',
command: (editor) => editor.chain().focus().toggleWrap('note').run(),
isActive: (editor) => editor.isActive('note'),
},
],
});
```

### Properties

| Property | Type | Description |
| ------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------- |
| `id` | string | A unique identifier for this extension. Lowercase letters, digits, and hyphens only. |
| `name` | string | The name shown in the field's **Extensions** option. |
| `extensions` | array | Tiptap extensions, nodes, and marks to add to the editor. Each node and mark must follow the [round-trip rule](#round-trip-rule). |
| `buttons` | array | [Toolbar buttons](#toolbar-buttons). Admins add them to a field in the **Toolbar** option. |
| `bubbleMenus` | array | [Bubble menus](#bubble-menus). They show on every field that enables the extension. |

:partial{content="extensions-uid"}

### Toolbar Buttons

A button runs a `command`, or opens a dropdown of `items`. It cannot have both.

| Property | Type | Description |
| ------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `key` | string | Unique inside the extension. Lowercase letters, digits, and hyphens only. |
| `icon` | string | An icon name from the [Google Material Icons set](https://fonts.google.com/icons). An unknown name shows the `extension` icon. |
| `label` | string | Plain text shown for the button. |
| `command` | function | Receives the Tiptap `editor` and runs when the user clicks the button. |
| `items` | array | Dropdown entries. Each has a `key`, `label`, `command`, and optional `icon`, `isActive`, and `isDisabled`. |
| `isActive` | function | Receives the `editor` and returns `true` to show the button as active. A dropdown without it is active when one of its items is. |
| `isDisabled` | function | Receives the `editor` and returns `true` to disable the button. A disabled or read-only field disables every button anyway. |

### Bubble Menus

A bubble menu floats next to the selection when `shouldShow` returns `true`.

| Property | Type | Description |
| ------------ | -------- | --------------------------------------------------------------------------- |
| `key` | string | Unique inside the extension. |
| `shouldShow` | function | Receives the `editor` and returns `true` to show the menu. |
| `buttons` | array | Toolbar buttons with a `command`. Dropdowns with `items` are not supported. |

The editor hides bubble menus while the field is not editable, and while the cursor is in a table. When several menus match, only the first one shows. See [Load Order](#load-order).

## Enable an Extension on a Field

An admin chooses which fields use an extension. Content in other fields is not affected.

1. Open the settings of a field that uses the WYSIWYG interface.
2. In **Extensions**, select your extension. This option shows only when at least one rich text extension is installed.
3. In **Toolbar**, select your extension's buttons. The list shows them after you select the extension.
4. Save the field.

The stored toolbar key of a contributed button is `<extension id>:<button key>`, for example `note:note`. A contributed key never replaces a core button.

The editor builds its schema when it opens. After you change a field's extensions, open the item again to use them.

### Load Order

Directus sorts rich text extensions by `id`. This order is the same on every deployment. It sets:

- Which extension wins when two use the same node or mark name.
- The order in which the editor tries their `parseHTML` rules.
- The order of contributed buttons in the toolbar, and which bubble menu shows when several match.

Contributed buttons go after the core buttons. When the toolbar is too narrow, they are the first to move into **Show More**.

## Round-Trip Rule

The WYSIWYG interface stores HTML. When a field loads, the editor parses the stored HTML with each `parseHTML` rule. When it saves, it writes the HTML that `renderHTML` returns.

Every node and mark must be symmetric. `parseHTML` must read back exactly the markup that `renderHTML` writes. If not, the saved HTML differs from the loaded HTML, and every save of a field that uses the extension warns the user that saving alters the content.

- Match each attribute and data attribute that `renderHTML` writes.
- Raise the extension's `priority` when a core rule matches the same markup first. For example, the core `textStyle` mark claims any `span` with a `data-*` attribute, so the `richtext` template sets `priority: 200` on its mark.

When the Data Studio loads, Directus checks each node and mark once and logs a console warning on a mismatch:

```
Richtext extension "note": node "note" does not survive a round trip. renderHTML writes <div data-note="">...</div>, which parses back as ...
```

The check is best effort. It uses default attribute values only, and skips nodes that cannot stand alone, such as a list item. It does not detect a clash between two extensions. Test your extension by saving a field, and by opening the source code view and the comparison view.

## Names and Validation

Directus validates each extension when the Data Studio loads. An extension that breaks a rule does not load, and the browser console shows the reason:

```
Richtext extension "note" was not loaded: "paragraph" is a core node, mark or extension name
```

Other extensions and the editor keep working. An extension is rejected when:

- Its `id` is not unique, or contains characters other than lowercase letters, digits, and hyphens.
- A node, mark, or extension uses a core name, such as `paragraph`, `heading`, `bold`, `link`, `table`, or `image`.
- A name starts with `customFormat_`. This prefix is reserved for the field's [custom formats](/guides/data-model/rich-text#custom-formats).
- A name is already used by another rich text extension. The extension whose `id` sorts first keeps the name.
- An attribute uses a name the core editor keeps on every element: `class`, `id`, `title`, `role`, `lang`, `dir`, `dataAttributes`, or `ariaAttributes`.
- A button, menu item, or bubble menu key is not unique in its list.
- It throws while Directus validates it or builds the editor schema with it.

## Error Handling

A failing extension fails alone.

- If the extension's code throws while it loads, Directus skips it.
- If a `command`, `isActive`, `isDisabled`, or `shouldShow` callback throws, the editor disables that button or menu until the editor closes. The console shows the error. The core buttons keep working.

## Node Views

A node can render with a Vue component in the editor. Use `VueNodeViewRenderer`, `NodeViewWrapper`, and `NodeViewContent` from the shared `@tiptap/vue-3` package.

```js
import { Node } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-3';
import CardView from './card-view.vue';

const Card = Node.create({
name: 'card',
group: 'block',
content: 'block+',
parseHTML: () => [{ tag: 'div[data-card]' }],
renderHTML: () => ['div', { 'data-card': '' }, 0],
addNodeView: () => VueNodeViewRenderer(CardView),
});
```

The field saves the `renderHTML` output, not the markup of the node view. Controls you add in the node view are never stored.

To hide edit controls when the field is not editable, use `useRichTextEditable()`. Do not use `editor.isEditable`. It does not update when the field later becomes read-only, disabled, or locked.

```vue
<template>
<NodeViewWrapper class="card">
<button v-if="editable" type="button" @click="deleteNode()">Remove</button>
<NodeViewContent />
</NodeViewWrapper>
</template>

<script setup>
import { useRichTextEditable } from '@directus/extensions-sdk';
import { NodeViewContent, NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3';

const { deleteNode } = defineProps(nodeViewProps);
const editable = useRichTextEditable();
</script>
```

`useRichTextEditable()` returns a read-only ref. Outside the WYSIWYG interface, its value is always `true`.

## Style Your Nodes

Import a CSS file in your entrypoint. The extensions SDK bundles it and adds it to the page when the extension loads.

Scope every rule under the `.richtext-content` class. The editor root has this class in edit mode, in read-only mode, and in the comparison view.

```css
.richtext-content div[data-note] {
padding: 12px 16px;
border-inline-start: 4px solid var(--theme--primary);
background-color: var(--theme--primary-background);
}
```

The core editor styles use low specificity, so your rules override them. The comparison view's change highlights always stay visible.

These styles apply in the Data Studio only. Your frontend renders the stored HTML, so add matching styles there. See [Rich Text](/guides/data-model/rich-text) for how to style the editor output on your frontend.

## Remove an Extension

When an admin removes an extension from a field, or uninstalls it, the field's schema no longer contains its nodes and marks. Content that still uses them follows the same flow as other markup the editor does not support:

1. The field opens read-only and shows a notice.
2. The user selects the field and can view the changes as a diff.
3. The user chooses **Keep Read-only**, **Edit Anyway**, or **Edit Raw HTML**.

**Edit Anyway** removes the extension's markup when the user saves. **Edit Raw HTML** edits the stored HTML as text and keeps it. Enable the extension again to edit the content in the editor without losing markup.

::callout{icon="i-lucide-triangle-alert" color="warning"}
Saved content keeps the extension's markup until a user saves the field without it. If a user saves without it by mistake, restore the earlier version from **Revisions**.
::

## Wrap an Existing Tiptap Package

You can add a published Tiptap extension package to Directus.

1. Install the package as a dependency of your extension. Use a version from the same Tiptap major as Directus.
2. Add it to `extensions`, with `.configure()` for its options.
3. Add buttons that run its commands.

```js
import { defineRichText } from '@directus/extensions-sdk';
import { Youtube } from '@tiptap/extension-youtube';

export default defineRichText({
id: 'youtube-embed',
name: 'YouTube',
extensions: [Youtube.extend({ priority: 1000 }).configure({ nocookie: true })],
buttons: [
{
key: 'embed',
icon: 'smart_display',
label: 'Embed YouTube video',
command: (editor) => {
const src = window.prompt('YouTube URL');
if (src) editor.chain().focus().setYoutubeVideo({ src }).run();
},
},
],
});
```

The extensions SDK bundles the package code but keeps its Tiptap and ProseMirror imports on the shared copy. Before you publish, check the package against the rules on this page:

- If its name is a core name, rename it with `.extend({ name: '...' })`.
- If a core rule claims its markup first, raise its `priority` with `.extend()`, as in the example. The core editor parses `iframe`, `video`, and `audio` elements as media.
- Save a field that uses it, and check the console for a round-trip warning.

## Tiptap Version Policy

A rich text extension uses the copy of Tiptap that ships with the Data Studio. The extensions SDK does not bundle the shared Tiptap packages, so the editor and your extension use the same ProseMirror classes. ProseMirror checks objects by class, so objects from a second copy of ProseMirror fail these checks and the editor behaves incorrectly.
Expand Down Expand Up @@ -47,7 +319,7 @@ The `host` field in your extension's `package.json` declares the Directus versio
"type": "richtext",
"path": "dist/index.js",
"source": "src/index.js",
"host": "^12.4.0"
"host": "^12.6.0"
}
```

Expand Down
Loading