Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ yarn-error.log*

# worktrees
.worktrees

# serena
.serena/
17 changes: 17 additions & 0 deletions packages/editor/examples/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { EmailThemingExample } from './email-theming';
import { FullEmailBuilder } from './full-email-builder';
import { Buttons } from './images-and-buttons';
import { LinkEditing } from './link-editing';
import { OneLineEditor } from './one-line-editor';
import { OneLineEditorFull } from './one-line-editor-full';
import { SlashCommands } from './slash-commands';

export interface ExampleItem {
Expand All @@ -22,6 +24,21 @@ export interface ExampleSection {
}

export const sections: ExampleSection[] = [
{
title: 'One-Line Editor',
examples: [
{
id: 'one-line-editor',
label: 'Minimal',
component: OneLineEditor,
},
{
id: 'one-line-editor-full',
label: 'Full Features',
component: OneLineEditorFull,
},
],
},
{
title: 'Getting Started',
examples: [
Expand Down
81 changes: 81 additions & 0 deletions packages/editor/examples/src/examples/one-line-editor-full.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { EmailEditor, type EmailEditorRef } from '@react-email/editor';
import { useRef, useState } from 'react';
import { ExampleShell } from '../example-shell';

const content = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Welcome to the Newsletter' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Edit this content, then use the buttons below to export or inspect the editor output. Try switching themes to see how the editor adapts.',
},
],
},
],
};

export function OneLineEditorFull() {
const editorRef = useRef<EmailEditorRef>(null);
const [theme, setTheme] = useState<'basic' | 'minimal'>('basic');
const [output, setOutput] = useState('');

const handleExportHtml = async () => {
if (!editorRef.current) return;
const result = await editorRef.current.export();
setOutput(result.html);
};

const handleGetJson = () => {
if (!editorRef.current) return;
setOutput(JSON.stringify(editorRef.current.getJSON(), null, 2));
};

return (
<ExampleShell
title="One-Line Editor — Full Features"
description="Theme switching, ref methods (export, getJSON), and callbacks — all with a single component."
>
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setTheme(theme === 'basic' ? 'minimal' : 'basic')}
className="px-3 py-1.5 border border-(--re-border) rounded-lg bg-(--re-bg) text-(--re-text) cursor-pointer text-[0.8125rem] hover:bg-(--re-hover)"
>
Theme: {theme}
</button>
<button
type="button"
onClick={handleExportHtml}
className="px-3 py-1.5 border border-(--re-border) rounded-lg bg-(--re-bg) text-(--re-text) cursor-pointer text-[0.8125rem] hover:bg-(--re-hover)"
>
Export HTML
</button>
<button
type="button"
onClick={handleGetJson}
className="px-3 py-1.5 border border-(--re-border) rounded-lg bg-(--re-bg) text-(--re-text) cursor-pointer text-[0.8125rem] hover:bg-(--re-hover)"
>
Get JSON
</button>
</div>

<EmailEditor ref={editorRef} content={content} theme={theme} />

{output && (
<textarea
readOnly
value={output}
className="mt-4 w-full h-64 p-3 font-mono text-xs bg-(--re-bg) text-(--re-text) border border-(--re-border) rounded-lg resize-y"
/>
)}
</ExampleShell>
);
}
28 changes: 28 additions & 0 deletions packages/editor/examples/src/examples/one-line-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { EmailEditor } from '@react-email/editor';
import { ExampleShell } from '../example-shell';

const content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is the simplest way to use the editor — a single component with everything pre-wired. Try selecting text to see the bubble menu, or type "/" for slash commands.',
},
],
},
],
};

export function OneLineEditor() {
return (
<ExampleShell
title="One-Line Editor"
description="The simplest setup — one component with everything included."
>
<EmailEditor content={content} />
</ExampleShell>
);
}
12 changes: 11 additions & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@react-email/editor",
"version": "0.0.0-experimental.31",
"version": "0.0.0-experimental.32",
"description": "",
"sideEffects": [
"**/*.css"
Expand All @@ -9,6 +9,16 @@
"dist/**"
],
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./core": {
"import": {
"types": "./dist/core/index.d.mts",
Expand Down
136 changes: 136 additions & 0 deletions packages/editor/src/email-editor/email-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { Content, Editor, Extensions, JSONContent } from '@tiptap/core';
import { UndoRedo } from '@tiptap/extensions';
import {
EditorProvider,
type UseEditorOptions,
useCurrentEditor,
} from '@tiptap/react';
import { forwardRef, type Ref, useImperativeHandle, useMemo } from 'react';
import { createDropHandler } from '../core/create-drop-handler';
import {
createPasteHandler,
type UploadImageHandler,
} from '../core/create-paste-handler';
import { composeReactEmail } from '../core/serializer/compose-react-email';
import { StarterKit } from '../extensions';
import { EmailTheming } from '../plugins/email-theming/extension';
import { BubbleMenuDefault } from '../ui/bubble-menu/default';
import { ButtonBubbleMenuDefault } from '../ui/button-bubble-menu/default';
import { ImageBubbleMenuDefault } from '../ui/image-bubble-menu/default';
import { LinkBubbleMenuDefault } from '../ui/link-bubble-menu/default';
import { SlashCommandRoot } from '../ui/slash-command/root';
import '../ui/themes/default.css';

export interface EmailEditorRef {
export: () => Promise<{ html: string; text: string }>;
getJSON: () => JSONContent;
getHTML: () => string;
editor: Editor | null;
}

export interface EmailEditorProps {
content?: Content;
onChange?: (editor: Editor) => void;
onUploadImage?: UploadImageHandler;
onReady?: (editor: Editor) => void;
theme?: 'basic' | 'minimal';
editable?: boolean;
placeholder?: string;
bubbleMenu?: {
excludeNodes?: string[];
excludeMarks?: string[];
};
extensions?: Extensions;
className?: string;
}

function RefBridge({ editorRef }: { editorRef: Ref<EmailEditorRef> }) {
const { editor } = useCurrentEditor();

useImperativeHandle(
editorRef,
() => ({
export: async () => {
if (!editor) {
return { html: '', text: '' };
}
return composeReactEmail({ editor });
},
getJSON: () => editor?.getJSON() ?? { type: 'doc', content: [] },
getHTML: () => editor?.getHTML() ?? '',
editor,
}),
[editor],
);

return null;
}

export const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(
function EmailEditor(
Comment thread
joaopcm marked this conversation as resolved.
Outdated
{
content,
onChange,
onUploadImage,
onReady,
theme = 'basic',
editable = true,
placeholder,
bubbleMenu,
extensions: extensionsProp,
className,
},
ref,
) {
const extensions = useMemo(() => {
if (extensionsProp) {
return extensionsProp;
}

return [
StarterKit.configure({
Placeholder: placeholder ? { placeholder } : undefined,
}),
UndoRedo,
Comment thread
gabrielmfern marked this conversation as resolved.
EmailTheming.configure({ theme }),
];
}, [extensionsProp, theme, placeholder]);

const editorProps: UseEditorOptions['editorProps'] = useMemo(
() => ({
handlePaste: createPasteHandler({
onUploadImage,
extensions,
}),
handleDrop: createDropHandler({
onUploadImage,
}),
}),
[onUploadImage, extensions],
);

return (
<EditorProvider
Comment thread
joaopcm marked this conversation as resolved.
key={theme}
extensions={extensions}
content={content}
editable={editable}
immediatelyRender={false}
editorProps={editorProps}
editorContainerProps={{ className }}
onCreate={({ editor }) => onReady?.(editor)}
onUpdate={({ editor }) => onChange?.(editor)}
>
<RefBridge editorRef={ref} />
<BubbleMenuDefault
excludeNodes={bubbleMenu?.excludeNodes ?? ['button']}
excludeMarks={bubbleMenu?.excludeMarks ?? ['link']}
/>
<LinkBubbleMenuDefault />
<ButtonBubbleMenuDefault />
<ImageBubbleMenuDefault />
<SlashCommandRoot />
</EditorProvider>
);
},
);
5 changes: 5 additions & 0 deletions packages/editor/src/email-editor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
EmailEditor,
type EmailEditorProps,
type EmailEditorRef,
} from './email-editor';
5 changes: 5 additions & 0 deletions packages/editor/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export {
EmailEditor,
type EmailEditorProps,
type EmailEditorRef,
} from './email-editor';
1 change: 1 addition & 0 deletions packages/editor/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig } from 'tsdown';

export default defineConfig({
entry: {
index: 'src/index.ts',
'core/index': 'src/core/index.ts',
'extensions/index': 'src/extensions/index.ts',
'plugins/index': 'src/plugins/index.ts',
Expand Down
Loading