Skip to content
Closed
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
290 changes: 290 additions & 0 deletions apps/docs/editor/advanced/custom-extensions.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
---
title: "Custom Extensions"
sidebarTitle: "Custom Extensions"
description: "Create custom email-compatible editor nodes and marks."
icon: "wrench"
---

## EmailNode vs TipTap Node

The editor uses `EmailNode` instead of TipTap's standard `Node`. The key difference is a
required `renderToReactEmail()` method that tells the serializer how to convert the node to
a React Email component for HTML export.

```
TipTap Node
├── name, group, content
├── parseHTML()
├── renderHTML() ← How it looks in the editor
└── ...

EmailNode (extends Node)
├── name, group, content
├── parseHTML()
├── renderHTML() ← How it looks in the editor
├── renderToReactEmail() ← How it looks in the exported email HTML
└── ...
```

## Creating a custom node

Here's a complete example of a custom "Callout" node that renders as a highlighted block:

```tsx
import { EmailNode } from '@react-email/editor/core';
import { mergeAttributes } from '@tiptap/core';

const Callout = EmailNode.create({
name: 'callout',
group: 'block',
content: 'inline*',

parseHTML() {
return [{ tag: 'div[data-callout]' }];
},

renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-callout': '',
style:
'padding: 12px 16px; background: #f4f4f5; border-left: 3px solid #1c1c1c; border-radius: 4px; margin: 8px 0;',
}),
0,
];
},

renderToReactEmail({ children, style }) {
return (
<div
style={{
...style,
padding: '12px 16px',
backgroundColor: '#f4f4f5',
borderLeft: '3px solid #1c1c1c',
borderRadius: '4px',
margin: '8px 0',
}}
>
{children}
</div>
);
},
});
```

Key methods:

- **`parseHTML()`** — Defines which HTML elements get parsed into this node (for clipboard paste, HTML content)
- **`renderHTML()`** — Controls how the node appears in the editor (in the browser DOM)
- **`renderToReactEmail()`** — Controls how the node is serialized when exporting to email HTML via `composeReactEmail`

## Registering the extension

Add your custom extension to the extensions array alongside `StarterKit`:

```tsx
const extensions = [StarterKit, Callout];
```

## Inserting custom nodes

Use the editor's `insertContent` command to programmatically insert your custom node:

```tsx
import { useCurrentEditor } from '@tiptap/react';

function Toolbar() {
const { editor } = useCurrentEditor();
if (!editor) return null;

return (
<button
onClick={() =>
editor
.chain()
.focus()
.insertContent({
type: 'callout',
content: [{ type: 'text', text: 'New callout' }],
})
.run()
}
>
Insert Callout
</button>
);
}
```

## Complete example

Here's the full editor setup with the custom Callout extension, a toolbar, and a bubble menu:

```tsx
import { EmailNode } from '@react-email/editor/core';
import { StarterKit } from '@react-email/editor/extensions';
import { BubbleMenu } from '@react-email/editor/ui';
import { mergeAttributes } from '@tiptap/core';
import { EditorProvider, useCurrentEditor } from '@tiptap/react';
import { Info } from 'lucide-react';

const Callout = EmailNode.create({
name: 'callout',
group: 'block',
content: 'inline*',

parseHTML() {
return [{ tag: 'div[data-callout]' }];
},

renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-callout': '',
style:
'padding: 12px 16px; background: #f4f4f5; border-left: 3px solid #1c1c1c; border-radius: 4px; margin: 8px 0;',
}),
0,
];
},

renderToReactEmail({ children, style }) {
return (
<div
style={{
...style,
padding: '12px 16px',
backgroundColor: '#f4f4f5',
borderLeft: '3px solid #1c1c1c',
borderRadius: '4px',
margin: '8px 0',
}}
>
{children}
</div>
);
},
});

const extensions = [StarterKit, Callout];

const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This editor includes a custom Callout node. Use the toolbar to insert one.',
},
],
},
{
type: 'callout',
content: [
{ type: 'text', text: 'This is a callout block — a custom extension!' },
],
},
],
};

function Toolbar() {
const { editor } = useCurrentEditor();
if (!editor) return null;

return (
<button
onClick={() =>
editor
.chain()
.focus()
.insertContent({
type: 'callout',
content: [{ type: 'text', text: 'New callout' }],
})
.run()
}
>
<Info size={16} />
Insert Callout
</button>
);
}

export function MyEditor() {
return (
<EditorProvider
extensions={extensions}
content={content}
slotBefore={<Toolbar />}
>
<BubbleMenu.Default />
</EditorProvider>
);
}
```

## EmailNode.from

Wrap an existing TipTap node with email serialization support:

```tsx
import { EmailNode } from '@react-email/editor/core';
import { Node } from '@tiptap/core';

const MyTipTapNode = Node.create({ /* ... */ });

const MyEmailNode = EmailNode.from(MyTipTapNode, ({ children, style }) => {
return <div style={style}>{children}</div>;
});
```

This is useful when you want to reuse a community TipTap extension and add email export support.

## EmailMark

For inline marks (like bold, italic, or custom annotations), use `EmailMark`:

```tsx
import { EmailMark } from '@react-email/editor/core';

const Highlight = EmailMark.create({
name: 'highlight',

parseHTML() {
return [{ tag: 'mark' }];
},

renderHTML({ HTMLAttributes }) {
return ['mark', HTMLAttributes, 0];
},

renderToReactEmail({ children, style }) {
return (
<mark style={{ ...style, backgroundColor: '#fef08a' }}>{children}</mark>
);
},
});
```

## Configure and extend

Both `EmailNode` and `EmailMark` support TipTap's standard customization methods:

```tsx
// Configure options
const CustomHeading = Heading.configure({ levels: [1, 2] });

// Extend with additional behavior
const CustomParagraph = Paragraph.extend({
addKeyboardShortcuts() {
return {
'Mod-Shift-p': () => this.editor.commands.setParagraph(),
};
},
});
```
102 changes: 102 additions & 0 deletions apps/docs/editor/advanced/event-bus.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
title: "Event Bus"
sidebarTitle: "Event Bus"
description: "Communicate between editor components using typed events."
icon: "tower-broadcast"
---

## Overview

The editor provides a typed event bus for communication between components. It's a singleton
instance built on the browser's native `CustomEvent` API with events prefixed as
`@react-email/editor:`.

```tsx
import { editorEventBus } from '@react-email/editor/core';
```

## Dispatching events

Fire an event with a payload:

```tsx
editorEventBus.dispatch('bubble-menu:add-link', undefined);
```

## Listening to events

Subscribe to events and clean up when done:

```tsx
import { useEffect } from 'react';
import { editorEventBus } from '@react-email/editor/core';

function MyComponent() {
useEffect(() => {
const subscription = editorEventBus.on('bubble-menu:add-link', () => {
console.log('Link addition triggered');
});

return () => {
subscription.unsubscribe();
};
}, []);

return null;
}
```

The `on` method returns an object with an `unsubscribe` function. Always unsubscribe in a
cleanup function to avoid memory leaks.

## Built-in events

| Event | Payload | Description |
|-------|---------|-------------|
| `bubble-menu:add-link` | `undefined` | Triggered when the "add link" action is initiated from the bubble menu |

## Adding custom events

Use TypeScript module augmentation to register custom events with full type safety:

```tsx
declare module '@react-email/editor/core' {
interface EditorEventMap {
'my-feature:custom-event': { data: string };
'my-feature:another-event': { count: number };
}
}
```

Then dispatch and listen with full type checking:

```tsx
// TypeScript knows the payload type
editorEventBus.dispatch('my-feature:custom-event', { data: 'hello' });

editorEventBus.on('my-feature:custom-event', (payload) => {
// payload is typed as { data: string }
console.log(payload.data);
});
```

## Event targets

By default, events are dispatched on `window`. You can scope events to a specific DOM element
using the `target` option:

```tsx
// Dispatch on a specific element
const container = document.getElementById('my-editor');
editorEventBus.dispatch('bubble-menu:add-link', undefined, {
target: container,
});

// Listen on a specific element
editorEventBus.on('bubble-menu:add-link', handler, {
target: container,
});
```

This is useful when you have multiple editor instances on the same page and want events
to stay scoped to their respective editors.
Loading
Loading