Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/editor/src/core/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const EVENT_PREFIX = '@react-email/editor:';
export interface EditorEventMap {
'bubble-menu:add-link': undefined;
'node-clicked': NodeClickedEvent;
'calendar-invite:open': { range: { from: number; to: number }; editorRef: object };
}

export type NodeClickedEvent = {
Expand Down
23 changes: 22 additions & 1 deletion packages/editor/src/email-editor/email-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@ import {
import { createPasteHandler } from '../core/create-paste-handler';
import { composeReactEmail } from '../core/serializer/compose-react-email';
import { StarterKit } from '../extensions';
import { CalendarInvite, CalendarInvitePlugin, calendarInviteSlashCommand, type CalendarPluginOptions } from '../plugins/calendar-invite';
import { EmailTheming } from '../plugins/email-theming/extension';
import type { EditorThemeInput } from '../plugins/email-theming/types';
import { createImageExtension } from '../plugins/image/extension';
import { BubbleMenu } from '../ui/bubble-menu';
import { defaultSlashCommands } from '../ui/slash-command/commands';
import { SlashCommandRoot } from '../ui/slash-command/root';
import '../ui/themes/default.css';
import { Placeholder } from '@tiptap/extension-placeholder';


export interface EmailEditorRef {
getEmail: () => Promise<{ html: string; text: string }>;
getEmailHTML: () => Promise<string>;
Expand All @@ -48,6 +51,8 @@ export interface EmailEditorProps {
onUploadImage?: (file: File) => Promise<{ url: string }>;
className?: string;
children?: ReactNode;
/** Options forwarded to the built-in CalendarInvitePlugin */
calendarInvite?: CalendarPluginOptions;
}

function buildRef(editor: Editor | null): EmailEditorRef {
Expand Down Expand Up @@ -129,6 +134,7 @@ export const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(
onUploadImage,
className,
children,
calendarInvite,
},
ref,
) => {
Expand All @@ -146,6 +152,7 @@ export const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(
const extensions = useMemo(() => {
const base = extensionsProp ?? [
StarterKit.configure(),
CalendarInvite.configure(),
Placeholder.configure({
placeholder:
placeholder ??
Expand All @@ -166,6 +173,19 @@ export const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(
return imageExtension ? [...base, imageExtension] : base;
}, [extensionsProp, theme, placeholder, imageExtension]);

const hasCalendarInvite = useMemo(
() => extensions.some((ext) => ext.name === 'calendarInvite'),
[extensions],
);

const slashCommands = useMemo(
() =>
hasCalendarInvite
? [...defaultSlashCommands, calendarInviteSlashCommand]
: defaultSlashCommands,
[hasCalendarInvite],
);

const editorProps: UseEditorOptions['editorProps'] = useMemo(
() => ({
handlePaste: createPasteHandler({
Expand Down Expand Up @@ -194,7 +214,8 @@ export const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(
<BubbleMenu.LinkDefault />
<BubbleMenu.ButtonDefault />
<BubbleMenu.ImageDefault />
<SlashCommandRoot />
<SlashCommandRoot items={slashCommands} />
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
{hasCalendarInvite && <CalendarInvitePlugin {...(calendarInvite ?? {})} />}
{children}
</EditorProvider>
);
Expand Down
88 changes: 88 additions & 0 deletions packages/editor/src/plugins/calendar-invite/editor-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { NodeViewWrapper } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { computeEndDateTime, formatDisplayTime } from './ical-generator';
import type { CalendarEvent } from './types';

const PROVIDERS = ['Google Calendar', 'Outlook', 'Apple Calendar', 'Yahoo Calendar'] as const;

export function CalendarEditorCard({ node }: NodeViewProps) {
const attrs = node.attrs as CalendarEvent & { accentColor?: string };
const { title, date, startTime, duration, timezone, location, accentColor } = attrs;
const isAllDay = duration === -1;

const formattedDate = date
? new Date(`${date}T12:00:00`).toLocaleDateString('en-US', {
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric',
})
: '—';

let formattedTime = 'All day';
if (!isAllDay && startTime) {
const end = computeEndDateTime(date, startTime, duration);
formattedTime = `${formatDisplayTime(startTime)} – ${formatDisplayTime(end.time)}`;
}

const tzShort = timezone.split('/').pop()?.replace(/_/g, ' ') ?? '';
const chipColor = accentColor ?? '#1c1c1c';

return (
<NodeViewWrapper>
<div
style={{
border: '1px solid var(--re-border, #e5e5e5)',
borderRadius: '10px',
overflow: 'hidden',
margin: '2px 0',
backgroundColor: 'var(--re-bg, #fff)',
cursor: 'default',
userSelect: 'none',
}}
contentEditable={false}
>
{/* Main content */}
<div style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '10px' }}>
<div style={{ width: '34px', height: '34px', borderRadius: '7px', backgroundColor: 'var(--re-active, rgba(0,0,0,0.06))', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: '17px' }}>
📅
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: '13px', color: 'var(--re-text, #1c1c1c)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginBottom: '2px' }}>
{title || 'Untitled Event'}
</div>
<div style={{ fontSize: '12px', color: 'var(--re-text-muted, #6b6b6b)' }}>
{formattedDate} · {formattedTime}
{!isAllDay && tzShort ? ` (${tzShort})` : ''}
</div>
{location ? (
<div style={{ fontSize: '12px', color: 'var(--re-text-muted, #6b6b6b)', marginTop: '1px' }}>
📍 {location}
</div>
) : null}
</div>
</div>
</div>

{/* Provider buttons */}
<div style={{ borderTop: '1px solid var(--re-border, #e5e5e5)', padding: '8px 14px', display: 'flex', gap: '5px', flexWrap: 'wrap' as const }}>
{PROVIDERS.map((label) => (
<span
key={label}
style={{
display: 'inline-block',
padding: '3px 10px',
borderRadius: '4px',
backgroundColor: chipColor,
color: '#fff',
fontSize: '11px',
fontWeight: 500,
opacity: 0.9,
}}
>
{label}
</span>
))}
</div>
</div>
</NodeViewWrapper>
);
}
179 changes: 179 additions & 0 deletions packages/editor/src/plugins/calendar-invite/extension.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { Range } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { Button as ReactEmailButton, Hr, Section, Text } from 'react-email';
import { editorEventBus } from '../../core/event-bus';
import { EmailNode } from '../../core/serializer/email-node';
import { CalendarEditorCard } from './editor-card';
import {
computeEndDateTime,
formatDisplayTime,
generateAppleCalendarUri,
generateGoogleCalendarUrl,
generateOutlookUrl,
generateYahooCalendarUrl,
} from './ical-generator';
import type { CalendarEvent } from './types';

declare module '@tiptap/core' {
interface Commands<ReturnType> {
calendarInvite: {
insertCalendarInvite: (event: CalendarEvent) => ReturnType;
openCalendarInviteModal: (range: Range) => ReturnType;
};
}
}

export const CalendarInvite = EmailNode.create({
name: 'calendarInvite',
group: 'block',
atom: true,
draggable: true,

addAttributes() {
return {
title: { default: '' },
date: { default: '' },
startTime: { default: '09:00' },
duration: { default: 60 },
timezone: { default: 'UTC' },
location: { default: '' },
description: { default: '' },
// Card style overrides
cardBg: { default: '#fafafa' },
accentColor: { default: '#1c1c1c' },
borderColor: { default: '#e5e5e5' },
};
},

parseHTML() {
return [{ tag: 'div[data-type="calendar-invite"]' }];
},

renderHTML({ HTMLAttributes }) {
return ['div', { 'data-type': 'calendar-invite', ...HTMLAttributes }];
},

addNodeView() {
return ReactNodeViewRenderer(CalendarEditorCard);
},

addCommands() {
return {
insertCalendarInvite:
(event: CalendarEvent) =>
({ commands }) =>
// Insert the node + a trailing paragraph so the cursor always lands
// in a text position. Without this, the atom node gets NodeSelection
// and the next insertion replaces it instead of appending.
commands.insertContent([
{ type: 'calendarInvite', attrs: event },
{ type: 'paragraph' },
]),

openCalendarInviteModal:
(range: Range) =>
({ editor }) => {
editorEventBus.dispatch('calendar-invite:open', { range, editorRef: editor });
return true;
},
};
},

renderToReactEmail({ node }) {
const event = node.attrs as CalendarEvent & {
cardBg: string;
accentColor: string;
borderColor: string;
};
const {
title, date, startTime, duration, timezone,
location, description,
cardBg = '#fafafa',
accentColor = '#1c1c1c',
borderColor = '#e5e5e5',
} = event;

const isAllDay = duration === -1;
const end = isAllDay ? null : computeEndDateTime(date, startTime, duration);

const googleUrl = generateGoogleCalendarUrl(event);
const outlookUrl = generateOutlookUrl(event);
const yahooUrl = generateYahooCalendarUrl(event);
const appleUrl = generateAppleCalendarUri(event);

const formattedDate = date
? new Date(`${date}T12:00:00`).toLocaleDateString('en-US', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
})
: '';

const formattedTime = isAllDay
? 'All day'
: `${formatDisplayTime(startTime)}${end ? ` – ${formatDisplayTime(end.time)}` : ''}`;

const tzLabel = timezone.split('/').pop()?.replace(/_/g, ' ') ?? timezone;

// ── provider buttons ──────────────────────────────────────────────────────
const providers = [
{ label: 'Google Calendar', href: googleUrl },
{ label: 'Outlook', href: outlookUrl },
{ label: 'Apple Calendar', href: appleUrl },
{ label: 'Yahoo Calendar', href: yahooUrl },
] as const;

const btnStyle: React.CSSProperties = {
display: 'inline-block',
padding: '7px 14px',
backgroundColor: accentColor,
color: '#ffffff',
textDecoration: 'none',
borderRadius: '6px',
fontSize: '12px',
fontWeight: '500' as const,
margin: '0 6px 6px 0',
};

return (
<Section
style={{
border: `1px solid ${borderColor}`,
borderRadius: '10px',
padding: '20px 24px',
marginBottom: '12px',
backgroundColor: cardBg,
}}
>
<Text style={{ fontSize: '18px', fontWeight: '700', margin: '0 0 10px', color: '#1c1c1c' }}>
{title}
</Text>
<Text style={{ color: '#444', margin: '3px 0', fontSize: '14px' }}>📅 {formattedDate}</Text>
<Text style={{ color: '#444', margin: '3px 0', fontSize: '14px' }}>
🕐 {formattedTime}
{!isAllDay ? <> · <span style={{ color: '#888' }}>{tzLabel}</span></> : null}
</Text>
{location ? (
<Text style={{ color: '#444', margin: '3px 0', fontSize: '14px' }}>📍 {location}</Text>
) : null}
{description ? (
<>
<Hr style={{ border: 'none', borderTop: `1px solid ${borderColor}`, margin: '14px 0 10px' }} />
<Text style={{ color: '#555', margin: '0', fontSize: '14px', lineHeight: '1.55' }}>
{description}
</Text>
</>
) : null}
<Hr style={{ border: 'none', borderTop: `1px solid ${borderColor}`, margin: '14px 0 0' }} />
<Section style={{ paddingTop: '14px' }}>
<Text style={{ color: '#888', fontSize: '11px', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.04em' }}>
Add to calendar
</Text>
{providers.map((p) => (
<ReactEmailButton key={p.label} href={p.href} style={btnStyle}>
{p.label}
</ReactEmailButton>
))}
</Section>
</Section>
);
},
});
Loading
Loading