Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 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 } };
}

export type NodeClickedEvent = {
Expand Down
11 changes: 10 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,18 @@ 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';

const slashCommands = [...defaultSlashCommands, calendarInviteSlashCommand];

export interface EmailEditorRef {
getEmail: () => Promise<{ html: string; text: string }>;
getEmailHTML: () => Promise<string>;
Expand All @@ -48,6 +52,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 +135,7 @@ export const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(
onUploadImage,
className,
children,
calendarInvite,
},
ref,
) => {
Expand All @@ -146,6 +153,7 @@ export const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(
const extensions = useMemo(() => {
const base = extensionsProp ?? [
StarterKit.configure(),
CalendarInvite.configure(),
Placeholder.configure({
placeholder:
placeholder ??
Expand Down Expand Up @@ -194,7 +202,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.
<CalendarInvitePlugin {...(calendarInvite ?? {})} />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Mounting CalendarInvitePlugin per editor currently wires all instances to a global window event, so one editor’s calendar action can trigger modals in other editors.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/editor/src/email-editor/email-editor.tsx, line 206:

<comment>Mounting `CalendarInvitePlugin` per editor currently wires all instances to a global `window` event, so one editor’s calendar action can trigger modals in other editors.</comment>

<file context>
@@ -194,7 +202,8 @@ export const EmailEditor = forwardRef<EmailEditorRef, EmailEditorProps>(
         <BubbleMenu.ImageDefault />
-        <SlashCommandRoot />
+        <SlashCommandRoot items={slashCommands} />
+        <CalendarInvitePlugin {...(calendarInvite ?? {})} />
         {children}
       </EditorProvider>
</file context>

{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) =>
() => {
editorEventBus.dispatch('calendar-invite:open', { range });
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