Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
ca83c78
add basic extensions and dependencies
gabrielmfern Feb 16, 2026
24a6f33
fix imports, types, rename a few things
gabrielmfern Feb 18, 2026
fb86272
lint
gabrielmfern Feb 18, 2026
02e8d44
experimental.0
gabrielmfern Feb 18, 2026
bdebc24
export from core too, move types to utils
gabrielmfern Feb 18, 2026
c13d2b1
add codeblock and heading
gabrielmfern Feb 18, 2026
0b1b2a6
add section node
gabrielmfern Feb 18, 2026
7a3174b
remove heading since it requires theming
gabrielmfern Feb 18, 2026
fb5ab5d
fix build
gabrielmfern Feb 18, 2026
0e0e738
update snaps
gabrielmfern Feb 18, 2026
4cf8dc9
lint
gabrielmfern Feb 18, 2026
d135363
remove render
gabrielmfern Feb 18, 2026
703145b
add ts-expect-erro
gabrielmfern Feb 18, 2026
2f5264b
update lock
gabrielmfern Feb 18, 2026
21b6081
unpin dependencies
gabrielmfern Feb 20, 2026
73b6a27
add table component
gabrielmfern Feb 20, 2026
d0cc78d
add style-attribute
gabrielmfern Feb 23, 2026
3578eb3
add other simple extensions and export them
gabrielmfern Feb 23, 2026
c9c79cd
bump version
gabrielmfern Feb 23, 2026
79e6742
no createPlaceholder
gabrielmfern Feb 23, 2026
b50db9d
bump
gabrielmfern Feb 23, 2026
0947891
export preview-text, bump
gabrielmfern Feb 23, 2026
5d8b191
feat(editor): add columns extension (#2973)
joaopcm Feb 24, 2026
43fd7de
feat(editor): delete when doing backspace in next empty node (#2974)
gabrielmfern Feb 24, 2026
da2267b
bump
gabrielmfern Feb 24, 2026
5b2e437
feat(editor): scope Ctrl+A/Cmd+A selection to code block content (#2981)
gabrielmfern Mar 2, 2026
def7a9c
feat(editor): tab indentation in code blocks respects selected theme'…
cursoragent Mar 2, 2026
8f4820d
fix(editor): resolve biome lint and formatting issues
cursoragent Mar 2, 2026
30fcca8
remove useless changes
gabrielmfern Mar 2, 2026
398788e
lint
gabrielmfern Mar 2, 2026
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
2 changes: 2 additions & 0 deletions packages/editor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# @react-email/editor

7 changes: 7 additions & 0 deletions packages/editor/license.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Copyright 2026 Plus Five Five, Inc

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
69 changes: 69 additions & 0 deletions packages/editor/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"name": "@react-email/editor",
"version": "0.0.0-experimental.6",
"description": "",
"sideEffects": false,
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist/**"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"license": "MIT",
"scripts": {
"build": "tsdown src/index.ts --format esm,cjs --dts --external react",
"build:watch": "tsdown src/index.ts --format esm,cjs --dts --external react --watch",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest"
},
"repository": {
"type": "git",
"url": "https://github.com/resend/react-email.git",
"directory": "packages/editor"
},
"keywords": [
"react",
"email"
],
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"@react-email/components": "workspace:*",
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
},
"dependencies": {
"@tiptap/core": "^3.17.1",
"@tiptap/extension-code-block": "^3.17.1",
"@tiptap/extension-heading": "^3.17.1",
"@tiptap/extension-horizontal-rule": "^3.17.1",
"@tiptap/extension-placeholder": "^3.17.1",
"@tiptap/html": "^3.17.1",
"@tiptap/pm": "^3.17.1",
"@tiptap/react": "^3.17.1",
"@tiptap/starter-kit": "^3.17.1",
"hast-util-from-html": "^2.0.3",
"prismjs": "^1.30.0"
},
"devDependencies": {
"@types/prismjs": "1.26.5",
"tsconfig": "workspace:*",
"typescript": "5.8.3"
},
"publishConfig": {
"access": "public"
}
}
Empty file added packages/editor/readme.md
Empty file.
45 changes: 45 additions & 0 deletions packages/editor/src/core/email-node.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Heading } from '@tiptap/extension-heading';
import { EmailNode } from './email-node';

describe('EmailNode', () => {
it('maintains all user-defined properties from Heading', () => {
const Component = vi.fn(() => 'some important value');
const CustomHeader = EmailNode.from(Heading, Component);
expect(CustomHeader).toBeInstanceOf(EmailNode);
expect(Heading.config).not.toHaveProperty('renderToReactEmail');

expect(CustomHeader.options).toStrictEqual(Heading.options);
expect(CustomHeader.storage).toStrictEqual(Heading.storage);
expect(CustomHeader.child).toStrictEqual(Heading.child);
expect(CustomHeader.type).toStrictEqual(Heading.type);
expect(CustomHeader.name).toStrictEqual(Heading.name);
expect(CustomHeader.parent).toStrictEqual(Heading.parent);
expect(CustomHeader.config).toHaveProperty('renderToReactEmail');

expect(
CustomHeader.config.renderToReactEmail(
{} as unknown as Parameters<
typeof CustomHeader.config.renderToReactEmail
>[0],
),
).toBe('some important value');
const configWithoutRender = { ...CustomHeader.config } as Record<
string,
unknown
>;
delete configWithoutRender.renderToReactEmail;
expect(configWithoutRender).toStrictEqual(Heading.config);
});

it('remains an EmailNode instance and preserves renderToReactEmail after configure()', () => {
const Component = vi.fn(() => 'rendered');
const CustomHeader = EmailNode.from(Heading, Component);

const configured = CustomHeader.configure({ levels: [1, 2] });

expect(configured).toBeInstanceOf(EmailNode);
expect(configured.config).toHaveProperty('renderToReactEmail');
expect(configured.config.renderToReactEmail).toBe(Component);
expect(configured.name).toBe(CustomHeader.name);
});
});
91 changes: 91 additions & 0 deletions packages/editor/src/core/email-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
type Editor,
type JSONContent,
Node,
type NodeConfig,
type NodeType,
} from '@tiptap/core';
import type { CssJs } from '../utils/types';

export type RendererComponent = (props: {
node: JSONContent;
styles: CssJs;
children?: React.ReactNode;
}) => React.ReactNode;

export interface EmailNodeConfig<Options, Storage>
extends NodeConfig<Options, Storage> {
renderToReactEmail: RendererComponent;
}

type ConfigParameter<Options, Storage> = Partial<
Omit<EmailNodeConfig<Options, Storage>, 'renderToReactEmail'>
> &
Pick<EmailNodeConfig<Options, Storage>, 'renderToReactEmail'>;

export class EmailNode<
Options = Record<string, never>,
Storage = Record<string, never>,
> extends Node<Options, Storage> {
declare config: EmailNodeConfig<Options, Storage>;

// biome-ignore lint/complexity/noUselessConstructor: This is only meant to change the types for config, hence why we keep it
constructor(config: ConfigParameter<Options, Storage>) {
super(config);
}

/**
* Create a new Node instance
* @param config - Node configuration object or a function that returns a configuration object
*/
static create<O = Record<string, never>, S = Record<string, never>>(
config: ConfigParameter<O, S> | (() => ConfigParameter<O, S>),
) {
// If the config is a function, execute it to get the configuration object
const resolvedConfig = typeof config === 'function' ? config() : config;
return new EmailNode<O, S>(resolvedConfig);
}

static from<O, S>(
node: Node<O, S>,
renderToReactEmail: RendererComponent,
): EmailNode<O, S> {
const customNode = EmailNode.create({} as ConfigParameter<O, S>);
// This only makes a shallow copy, so if there's nested objects here mutating things will be dangerous
Object.assign(customNode, { ...node });
customNode.config = { ...node.config, renderToReactEmail };
return customNode;
}

configure(options?: Partial<Options>) {
return super.configure(options) as EmailNode<Options, Storage>;
}

extend<
ExtendedOptions = Options,
ExtendedStorage = Storage,
ExtendedConfig extends NodeConfig<
ExtendedOptions,
ExtendedStorage
> = EmailNodeConfig<ExtendedOptions, ExtendedStorage>,
>(
extendedConfig?:
| (() => Partial<ExtendedConfig>)
| (Partial<ExtendedConfig> &
ThisType<{
name: string;
options: ExtendedOptions;
storage: ExtendedStorage;
editor: Editor;
type: NodeType;
}>),
): EmailNode<ExtendedOptions, ExtendedStorage> {
// If the extended config is a function, execute it to get the configuration object
const resolvedConfig =
typeof extendedConfig === 'function' ? extendedConfig() : extendedConfig;
return super.extend(resolvedConfig) as EmailNode<
ExtendedOptions,
ExtendedStorage
>;
}
}
1 change: 1 addition & 0 deletions packages/editor/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './email-node';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`Body Node > renders React Email properly 1`] = `
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<div class="body-class" style="margin:0;padding:0">Body content</div>
<!--/$-->
"
`;
36 changes: 36 additions & 0 deletions packages/editor/src/extensions/__snapshots__/button.spec.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`EditorButton Node > renders React Email properly 1`] = `
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<table
align="center"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation">
<tbody style="width:100%">
<tr style="width:100%">
<td align="center" data-id="__react-email-column">
<a
class="button"
href="https://example.com"
style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;margin:0;padding:0;padding-top:0;padding-right:0;padding-bottom:0;padding-left:0"
target="_blank"
><span
><!--[if mso]><i style="mso-font-width:0%;mso-text-raise:0" hidden></i><![endif]--></span
><span
style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:0"
>Click me</span
><span
><!--[if mso]><i style="mso-font-width:0%" hidden>&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<!--/$-->
"
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`CodeBlockPrism Node > renders React Email properly 1`] = `
"<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<!--$-->
<pre
style="color:#f8f8f2;background:#282a36;text-shadow:0 1px rgba(0, 0, 0, 0.3);font-family:monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;hyphens:none;padding:0.75rem 1rem;margin:.5em 0;overflow:auto;border-radius:0.125rem;width:auto;font-weight:500;font-size:.92em"><code><span style="color:#8be9fd">const</span><span> ‍​x ‍​</span><span style="color:#f8f8f2">=</span><span> ‍​</span><span style="color:#bd93f9">1</span><span style="color:#f8f8f2">;</span><br/></code></pre>
<!--/$-->
"
`;
Loading
Loading