如何用 EmailNode 的 renderToReactEmail 为 React Email Editor 创建自定义邮件节点
【免费下载链接】react-email💌 Build and send emails using React项目地址: https://gitcode.com/GitHub_Trending/re/react-email
这篇文章解决的问题是:在 react-email 的@react-email/editor(基于 TipTap 的邮件编辑器)中,内置的StarterKit只覆盖了一组邮件场景需要的节点;当你需要一种内置节点之外的结构——比如带高亮底色的 Callout 提示块——并且希望它在编辑器里能输入、能粘贴识别、还能在导出时序列化成邮件 HTML,就需要用EmailNode创建一个自定义扩展,并实现其中唯一的新增必选方法renderToReactEmail()。
完成后的结果是:编辑器里可以插入自定义节点,调用composeReactEmail导出时,该节点会走你的renderToReactEmail渲染逻辑,出现在最终html输出中。
适用前提(来自编辑器文档的 Prerequisites 说明):
- React 18+;
- 使用支持 package exports 的打包器(Vite、Next.js、Webpack 5 等);
@react-email/editor包要求 Node >= 20(见 packages/editor/package.json 的engines字段)。
安装编辑器包
npm install @react-email/editor文档同时给出 yarn / pnpm / bun 的等价命令(yarn add/pnpm add/bun add @react-email/editor)。本文后续都基于低层级路径(EditorProvider+ 扩展数组),因为自定义节点必须显式加入 extensions 数组;如果你使用独立的EmailEditor组件,文档建议改走低层级路径来获得对扩展列表的完全控制(见 getting-started.mdx 的 "Lower-level setup")。
EmailNode 与 renderToReactEmail 的工作机制
EmailNode扩展了 TipTap 的Node类,额外要求一个renderToReactEmail()方法,它决定节点在通过composeReactEmail导出为邮件 HTML 时如何被序列化(见 email-node.mdx)。
创建扩展的配置对象接受所有标准 TipTap Node 选项,加上renderToReactEmail。文档中三个方法的分工:
parseHTML():识别粘贴或导入的 HTML,让已有内容能被解析回该节点;renderHTML():控制节点在编辑器内的外观;renderToReactEmail():控制导出时该节点渲染成什么 React Email 结构。
renderToReactEmail收到的 props(来自 email-node.mdx 的 props 表):
| Prop | Type | 说明 |
|---|---|---|
children | React.ReactNode | 该节点序列化后的子内容 |
style | React.CSSProperties | 该节点解析后的主题样式(无主题时为空对象) |
node | Node | ProseMirror 节点实例 |
extension | EmailNode | 扩展实例,可用于访问options |
导出的样式解析优先级(高者胜出,来自 compose-react-email.mdx):
- 节点属性上的内联样式;
- 当前主题通过
getNodeStyles()提供的主题样式; - 各扩展
renderToReactEmail()中写死的默认样式。
因此在渲染器中通常是{...style}展开主题样式,再叠加固定样式。
一个关键限制:composeReactEmail遍历文档时,未注册、或不是EmailNode的节点类型会渲染为null。所以自定义节点必须用EmailNode.create(或EmailNode.from)创建并注册,否则导出时会直接消失。
创建自定义 Callout 节点
下面是文档给出的完整示例:一个渲染为高亮块的 Callout 节点(示例代码来自 custom-extensions.mdx):
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> ); }, });三个方法要分别保持一致:parseHTML用div[data-callout]作为导入识别特征,renderHTML输出相同的data-callout属性和编辑器内样式,renderToReactEmail则把同样的视觉效果用 React 样式对象写出,供render()生成邮件 HTML。
注册扩展并插入自定义节点
把自定义扩展加入 extensions 数组,与StarterKit并列:
const extensions = [StarterKit, Callout];程序化插入使用编辑器的insertContent命令。文档示例是一个工具栏按钮:
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> ); }完整编辑器组合
把节点、工具栏和 BubbleMenu 组合进EditorProvider(同样来自 custom-extensions.mdx):
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 /> </EditorProvider> ); }注意content用的是 TipTap JSON 格式,编辑器同时支持 HTML 字符串与 TipTap JSON 两种初始内容格式(见 getting-started.mdx 的 "Content format")。使用低层级 UI 组件时记得导入主题 CSS,例如import '@react-email/editor/themes/default.css';,否则编辑器无样式。
验证导出结果
自定义节点是否真正参与邮件序列化,以导出输出为准。composeReactEmail的签名与返回值(来自 compose-react-email.mdx):
import { composeReactEmail } from '@react-email/editor/core'; async function composeReactEmail(params: { editor: Editor; preview: string | null; }): Promise<{ html: string; text: string }>;它读取editor.getJSON(),遍历每个节点和 mark,调用各扩展的renderToReactEmail(),应用主题样式,包上邮件模板(BaseTemplate),最后用 react-email 的render()同时产出html与纯文本text。
文档给出的带导出面板的验证方式(示例结果见 email-export.mdx):
import { composeReactEmail } from '@react-email/editor/core'; import { useCurrentEditor } from '@tiptap/react'; import { useState } from 'react'; function ExportPanel() { const { editor } = useCurrentEditor(); const [html, setHtml] = useState(''); const [exporting, setExporting] = useState(false); const handleExport = async () => { if (!editor) return; setExporting(true); const result = await composeReactEmail({ editor, preview: null }); setHtml(result.html); setExporting(false); }; return ( <div> <button onClick={handleExport} disabled={exporting}> {exporting ? 'Exporting...' : 'Export HTML'} </button> {html && ( <textarea readOnly value={html} rows={16} style={{ width: '100%', fontFamily: 'monospace' }} /> )} </div> ); }把ExportPanel作为EditorProvider的子组件(与Toolbar一样需要在 provider 内使用useCurrentEditor)。导出后的检查点:
html中应出现 Callout 节点renderToReactEmail写出的结构与内联样式(如padding: 12px 16px、border-left: 3px solid #1c1c1c),并且内容被BaseTemplate包裹(默认模板包含 viewport meta 与Preview占位);- 若文档中存在未注册的节点类型,它们在
html中不会有任何输出(渲染为null),这是排查"导出后内容缺失"的依据; - 如果 extensions 数组里配置了
EmailTheming,主题样式会自动注入到每个节点;没有SerializerPlugin时,style默认为空对象、使用内置DefaultBaseTemplate。
复用已有 TipTap 扩展(可选分支)
不想从零写节点时,EmailNode.from()可以把现有 TipTap 节点包一层邮件序列化能力,第二个参数就是renderToReactEmail渲染器(示例同样来自 custom-extensions.mdx):
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>; });EmailMark.from是 mark(行内样式,如高亮)的等价方法。EmailNode与EmailMark同时支持 TipTap 标准的.configure()和.extend(),而且.extend时也可以直接覆盖renderToReactEmail,例如文档示例中给Paragraph加键盘快捷键的同时重写其导出渲染:
const CustomParagraph = Paragraph.extend({ renderToReactEmail({ children, style }) { return <p style={{ ...style, lineHeight: '1.8' }}>{children}</p>; }, });边界与注意事项
- 自定义节点必须走
EditorProvider+ 显式 extensions 数组这条路;renderToReactEmail只在composeReactEmail(及EmailEditorref 的getEmailHTML/getEmailText/getEmail,三者底层都是它)导出时被调用。 styleprop 在无主题时是空对象,主题样式、内联样式与扩展默认样式的合并优先级以第 3 节列表为准,渲染器里先展开...style再叠加固定样式是文档给出的合并方式。depth只在列表节点(bulletList、orderedList)内递增,用于区分嵌套段落(listParagraph)与顶层段落(paragraph)的主题键;如果你的自定义节点只在列表外使用,这一点不影响其渲染。- 文档中的 EmailNode 参考 与 composeReactEmail 参考 给出了
create/from/configure/extend的完整 API 细节,可作为扩展开发时的查阅入口。
【免费下载链接】react-email💌 Build and send emails using React项目地址: https://gitcode.com/GitHub_Trending/re/react-email
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考