Skip to content
Merged
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
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.36",
"version": "0.0.0-experimental.37",
"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
138 changes: 138 additions & 0 deletions packages/editor/src/email-editor/email-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
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>(
(
{
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>
);
},
);

EmailEditor.displayName = 'EmailEditor';
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