-
Notifications
You must be signed in to change notification settings - Fork 950
chore(docs): add comprehensive editor package documentation #3097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
+2,646
−0
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(), | ||
| }; | ||
| }, | ||
| }); | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.