Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,261 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {Node, mergeAttributes} from '@tiptap/core';
import {ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps} from '@tiptap/react';
import {useEffect, useRef, useState} from 'react';
interface ImageAttrs {
src: string;
alt?: string;
title?: string;
width?: number | string;
height?: number | string;
}
function ResizableImageComponent({node, updateAttributes, selected}: ReactNodeViewProps) {
const attrs = node.attrs as ImageAttrs;
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<'se' | 'sw' | 'ne' | 'nw' | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const startPos = useRef({x: 0, y: 0});
const startSize = useRef({width: 0, height: 0});
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!imageRef.current || !resizeDirection) return;
const deltaX = e.clientX - startPos.current.x;
let newWidth = startSize.current.width;
let newHeight = startSize.current.height;
// Calculate new dimensions based on resize direction
if (resizeDirection.includes('e')) {
newWidth = startSize.current.width + deltaX;
} else if (resizeDirection.includes('w')) {
newWidth = startSize.current.width - deltaX;
}
// Maintain aspect ratio
const aspectRatio = startSize.current.width / startSize.current.height;
newHeight = newWidth / aspectRatio;
// Enforce minimum size
newWidth = Math.max(50, newWidth);
newHeight = Math.max(50, newHeight);
updateAttributes({
width: Math.round(newWidth),
height: Math.round(newHeight),
});
};
const handleMouseUp = () => {
setIsResizing(false);
setResizeDirection(null);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing, resizeDirection, updateAttributes]);
const handleResizeStart = (e: React.MouseEvent, direction: 'se' | 'sw' | 'ne' | 'nw') => {
e.preventDefault();
e.stopPropagation();
if (!imageRef.current) return;
const rect = imageRef.current.getBoundingClientRect();
startPos.current = {x: e.clientX, y: e.clientY};
startSize.current = {
width: rect.width,
height: rect.height,
};
setIsResizing(true);
setResizeDirection(direction);
};
const {src, alt, title, width, height} = attrs;
return (
<NodeViewWrapper className="resizable-image-wrapper">
<div
className={`resizable-image-container ${selected ? 'selected' : ''}`}
style={{display: 'inline-block', position: 'relative', maxWidth: '100%'}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
src={src}
alt={alt || ''}
title={title || ''}
width={width}
height={height}
className="email-image"
style={{
display: 'block',
maxWidth: '100%',
height: 'auto',
width: width ? `${width}px` : 'auto',
}}
/>
{selected && (
<>
{/* Resize handles */}
<div
className="resize-handle resize-handle-se"
onMouseDown={e => handleResizeStart(e, 'se')}
style={{
position: 'absolute',
bottom: '-4px',
right: '-4px',
width: '12px',
height: '12px',
backgroundColor: '#3b82f6',
border: '2px solid white',
borderRadius: '50%',
cursor: 'se-resize',
zIndex: 10,
}}
/>
<div
className="resize-handle resize-handle-sw"
onMouseDown={e => handleResizeStart(e, 'sw')}
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '12px',
height: '12px',
backgroundColor: '#3b82f6',
border: '2px solid white',
borderRadius: '50%',
cursor: 'sw-resize',
zIndex: 10,
}}
/>
<div
className="resize-handle resize-handle-ne"
onMouseDown={e => handleResizeStart(e, 'ne')}
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '12px',
height: '12px',
backgroundColor: '#3b82f6',
border: '2px solid white',
borderRadius: '50%',
cursor: 'ne-resize',
zIndex: 10,
}}
/>
<div
className="resize-handle resize-handle-nw"
onMouseDown={e => handleResizeStart(e, 'nw')}
style={{
position: 'absolute',
top: '-4px',
left: '-4px',
width: '12px',
height: '12px',
backgroundColor: '#3b82f6',
border: '2px solid white',
borderRadius: '50%',
cursor: 'nw-resize',
zIndex: 10,
}}
/>
</>
)}
</div>
</NodeViewWrapper>
);
}
export const ResizableImage = Node.create({
name: 'image',
group: 'block',
draggable: true,
inline: false,
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
title: {
default: null,
},
width: {
default: null,
parseHTML: element => {
const width = element.getAttribute('width');
return width ? parseInt(width, 10) : null;
},
renderHTML: attributes => {
if (!attributes.width) {
return {};
}
return {
width: attributes.width,
};
},
},
height: {
default: null,
parseHTML: element => {
const height = element.getAttribute('height');
return height ? parseInt(height, 10) : null;
},
renderHTML: attributes => {
if (!attributes.height) {
return {};
}
return {
height: attributes.height,
};
},
},
};
},
parseHTML() {
return [
{
tag: 'img[src]',
},
];
},
renderHTML({HTMLAttributes}) {
return ['img', mergeAttributes(HTMLAttributes, {class: 'email-image'})];
},
addNodeView() {
return ReactNodeViewRenderer(ResizableImageComponent);
},
addCommands(): any {
return {
setImage:
(options: {src: string; alt?: string; title?: string; width?: number; height?: number}) =>
({commands}: {commands: {insertContent: (content: unknown) => boolean}}) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
});
@@ -0,0 +1,506 @@
import {type Editor} from '@tiptap/react';
import {
AlignCenter,
AlignJustify,
AlignLeft,
AlignRight,
Bold,
Code,
Heading1,
Heading2,
Heading3,
Image,
Italic,
Link,
List,
ListOrdered,
Palette,
Quote,
Redo,
Strikethrough,
Undo,
Variable,
} from 'lucide-react';
import {Button, Input} from '@plunk/ui';
import {useEffect, useState} from 'react';
interface ToolbarProps {
editor: Editor | null;
onInsertVariable: () => void;
onInsertImage: () => void;
canUploadImages: boolean;
}
export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImages}: ToolbarProps) {
const [showLinkInput, setShowLinkInput] = useState(false);
const [linkUrl, setLinkUrl] = useState('');
const [showColorPicker, setShowColorPicker] = useState(false);
const [selectedColor, setSelectedColor] = useState('#000000');
const [customColor, setCustomColor] = useState('');
const [, forceUpdate] = useState({});
// Force re-render when editor state changes
useEffect(() => {
if (!editor) return;
const updateHandler = () => {
forceUpdate({});
};
editor.on('selectionUpdate', updateHandler);
editor.on('transaction', updateHandler);
return () => {
editor.off('selectionUpdate', updateHandler);
editor.off('transaction', updateHandler);
};
}, [editor]);
if (!editor) {
return null;
}
const addLink = () => {
if (linkUrl) {
// If updating an existing link, extend selection to cover the entire link first
if (editor.isActive('link')) {
editor.chain().focus().extendMarkRange('link').setLink({href: linkUrl}).run();
} else {
editor.chain().focus().setLink({href: linkUrl}).run();
}
setLinkUrl('');
setShowLinkInput(false);
}
};
const removeLink = () => {
editor.chain().focus().unsetLink().run();
setLinkUrl('');
setShowLinkInput(false);
};
const setColor = (color: string) => {
editor.chain().focus().setColor(color).run();
setSelectedColor(color);
};
const applyCustomColor = () => {
if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) {
setColor(customColor);
setCustomColor('');
setShowColorPicker(false);
}
};
// Tailwind color palette organized by hue
const colorGroups = [
{
name: 'Neutrals',
colors: ['#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#F3F4F6', '#FFFFFF'],
},
{
name: 'Reds',
colors: ['#7F1D1D', '#991B1B', '#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FEE2E2'],
},
{
name: 'Oranges',
colors: ['#7C2D12', '#C2410C', '#EA580C', '#F97316', '#FB923C', '#FDBA74', '#FED7AA'],
},
{
name: 'Yellows',
colors: ['#713F12', '#A16207', '#CA8A04', '#EAB308', '#FACC15', '#FDE047', '#FEF08A'],
},
{
name: 'Greens',
colors: ['#14532D', '#15803D', '#16A34A', '#22C55E', '#4ADE80', '#86EFAC', '#BBF7D0'],
},
{
name: 'Blues',
colors: ['#1E3A8A', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#DBEAFE'],
},
{
name: 'Purples',
colors: ['#581C87', '#6B21A8', '#7C3AED', '#8B5CF6', '#A78BFA', '#C4B5FD', '#E9D5FF'],
},
{
name: 'Pinks',
colors: ['#831843', '#9F1239', '#DB2777', '#EC4899', '#F472B6', '#F9A8D4', '#FBCFE8'],
},
];
return (
<div className="border-b border-neutral-200 bg-neutral-50 p-2 flex flex-wrap gap-1 sticky top-0 z-10">
{/* History */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
className="h-8 w-8"
>
<Undo className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
className="h-8 w-8"
>
<Redo className="h-4 w-4" />
</Button>
</div>
{/* Text formatting */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBold().run()}
data-active={editor.isActive('bold')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Bold className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleItalic().run()}
data-active={editor.isActive('italic')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Italic className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleStrike().run()}
data-active={editor.isActive('strike')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Strikethrough className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleCode().run()}
data-active={editor.isActive('code')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Code className="h-4 w-4" />
</Button>
</div>
{/* Headings */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 1}).run()}
data-active={editor.isActive('heading', {level: 1})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading1 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 2}).run()}
data-active={editor.isActive('heading', {level: 2})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading2 className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleHeading({level: 3}).run()}
data-active={editor.isActive('heading', {level: 3})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Heading3 className="h-4 w-4" />
</Button>
</div>
{/* Lists */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBulletList().run()}
data-active={editor.isActive('bulletList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<List className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
data-active={editor.isActive('orderedList')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
data-active={editor.isActive('blockquote')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Quote className="h-4 w-4" />
</Button>
</div>
{/* Alignment */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
data-active={editor.isActive({textAlign: 'left'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
data-active={editor.isActive({textAlign: 'center'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
data-active={editor.isActive({textAlign: 'right'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignRight className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
data-active={editor.isActive({textAlign: 'justify'})}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<AlignJustify className="h-4 w-4" />
</Button>
</div>
{/* Color picker */}
<div className="relative pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => setShowColorPicker(!showColorPicker)}
className="h-8 w-8"
>
<Palette className="h-4 w-4" />
</Button>
{showColorPicker && (
<div
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-20 max-h-96 overflow-y-auto"
style={{width: '280px'}}
>
{/* Custom color input */}
<div className="mb-3 pb-3 border-b border-neutral-200">
<label className="text-xs font-medium text-neutral-600 mb-1 block">Custom Color</label>
<div className="flex gap-2">
<Input
type="text"
value={customColor}
onChange={e => setCustomColor(e.target.value.toUpperCase())}
placeholder="#000000"
className="h-8 text-xs font-mono"
maxLength={7}
onKeyDown={e => {
if (e.key === 'Enter') {
applyCustomColor();
}
}}
/>
<Button
type="button"
size="sm"
onMouseDown={e => e.preventDefault()}
onClick={applyCustomColor}
disabled={!customColor || !/^#[0-9A-F]{6}$/i.test(customColor)}
className="h-8"
>
Apply
</Button>
</div>
</div>
{/* Color palette */}
<div className="space-y-3">
{colorGroups.map(group => (
<div key={group.name}>
<label className="text-xs font-medium text-neutral-600 mb-1.5 block">{group.name}</label>
<div className="grid grid-cols-7 gap-1.5">
{group.colors.map(color => (
<button
key={color}
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => {
setColor(color);
setShowColorPicker(false);
}}
className="w-8 h-8 rounded border-2 border-neutral-300 hover:border-neutral-500 hover:scale-110 transition-all relative group"
style={{backgroundColor: color}}
title={color}
>
{selectedColor === color && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
</div>
)}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Link */}
<div className="relative pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={() => {
if (editor.isActive('link')) {
// Get the current link URL and show the input to edit it
const previousUrl = editor.getAttributes('link').href || '';
setLinkUrl(previousUrl);
setShowLinkInput(true);
} else {
setShowLinkInput(!showLinkInput);
setLinkUrl('');
}
}}
data-active={editor.isActive('link')}
className="h-8 w-8 data-[active=true]:bg-neutral-200"
>
<Link className="h-4 w-4" />
</Button>
{showLinkInput && (
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-20 min-w-max">
<div className="flex gap-2 mb-2">
<input
type="url"
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
placeholder="https://example.com"
className="px-2 py-1 text-sm border border-neutral-200 rounded w-64"
onKeyDown={e => {
if (e.key === 'Enter') {
addLink();
} else if (e.key === 'Escape') {
setShowLinkInput(false);
setLinkUrl('');
}
}}
autoFocus
/>
<Button type="button" size="sm" onMouseDown={e => e.preventDefault()} onClick={addLink}>
{editor.isActive('link') ? 'Update' : 'Add'}
</Button>
</div>
{editor.isActive('link') && (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="destructive"
onMouseDown={e => e.preventDefault()}
onClick={removeLink}
>
Remove Link
</Button>
</div>
)}
</div>
)}
</div>
{/* Image */}
<div className="flex gap-0.5 pr-2 border-r border-neutral-200">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={onInsertImage}
disabled={!canUploadImages}
className="h-8 w-8"
title={canUploadImages ? 'Insert image' : 'Storage not configured'}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<Image className="h-4 w-4" />
</Button>
</div>
{/* Variable */}
<div className="flex gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
onMouseDown={e => e.preventDefault()}
onClick={onInsertVariable}
className="h-8 w-8"
title="Insert variable"
>
<Variable className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
import {mergeAttributes, Node} from '@tiptap/core';
import {Plugin, PluginKey} from '@tiptap/pm/state';
import {Decoration, DecorationSet} from '@tiptap/pm/view';
export interface VariableOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
variable: {
/**
* Insert a variable at the current position
*/
insertVariable: (name: string) => ReturnType;
};
}
}
export const Variable = Node.create<VariableOptions>({
name: 'variable',
group: 'inline',
inline: true,
selectable: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {},
};
},
addAttributes() {
return {
name: {
default: null,
parseHTML: element => element.getAttribute('data-variable'),
renderHTML: attributes => {
if (!attributes.name) {
return {};
}
return {
'data-variable': attributes.name,
};
},
},
};
},
parseHTML() {
return [
{
tag: 'span[data-variable]',
},
];
},
renderHTML({node, HTMLAttributes}) {
return [
'span',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-variable': node.attrs.name,
'class': 'variable-placeholder',
}),
`{{${node.attrs.name}}}`,
];
},
addCommands() {
return {
insertVariable:
(name: string) =>
({commands}) => {
return commands.insertContent({
type: this.name,
attrs: {name},
});
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('variableAutodetect'),
props: {
decorations: ({doc}) => {
const decorations: Decoration[] = [];
const regex = /\{\{([^}]+)\}\}/g;
doc.descendants((node, pos) => {
if (node.isText && node.text) {
let match;
while ((match = regex.exec(node.text)) !== null) {
const from = pos + match.index;
const to = from + match[0].length;
decorations.push(
Decoration.inline(from, to, {
class: 'variable-highlight',
}),
);
}
}
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
},
});
@@ -0,0 +1,220 @@
import {Mention} from '@tiptap/extension-mention';
import type {Editor, Range} from '@tiptap/core';
import tippy, {Instance as TippyInstance, sticky} from 'tippy.js';
import type {SuggestionProps} from '@tiptap/suggestion';
// This will be set from the component
let availableVariables: string[] = [];
export function setAvailableVariables(variables: string[]) {
availableVariables = variables || [];
}
// Suggestion component that will be rendered
class VariableSuggestionList {
public element: HTMLDivElement;
private items: string[];
private selectedIndex: number;
private command: (props: {id: string}) => void;
constructor(props: SuggestionProps) {
this.items = Array.isArray(props.items) ? props.items : [];
this.selectedIndex = 0;
this.command = props.command;
this.element = document.createElement('div');
this.element.className = 'variable-suggestion-list';
this.render();
}
render() {
if (!Array.isArray(this.items) || this.items.length === 0) {
this.element.innerHTML = '<div class="suggestion-item-empty">No variables found</div>';
return;
}
this.element.innerHTML = this.items
.map(
(item, index) => `
<div class="suggestion-item${index === this.selectedIndex ? ' is-selected' : ''}" data-index="${index}">
<code>{{${item}}}</code>
</div>
`,
)
.join('');
// Add click handlers
this.element.querySelectorAll('.suggestion-item').forEach((el, index) => {
el.addEventListener('click', () => {
this.selectItem(index);
});
});
}
selectItem(index: number) {
const item = this.items[index];
if (item && this.command) {
this.command({id: item});
}
}
onKeyDown(event: KeyboardEvent): boolean {
if (event.key === 'ArrowUp') {
this.upHandler();
return true;
}
if (event.key === 'ArrowDown') {
this.downHandler();
return true;
}
if (event.key === 'Enter') {
this.enterHandler();
return true;
}
return false;
}
upHandler() {
if (!Array.isArray(this.items) || this.items.length === 0) return;
this.selectedIndex = (this.selectedIndex + this.items.length - 1) % this.items.length;
this.render();
}
downHandler() {
if (!Array.isArray(this.items) || this.items.length === 0) return;
this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
this.render();
}
enterHandler() {
if (!Array.isArray(this.items) || this.items.length === 0) return;
this.selectItem(this.selectedIndex);
}
update(props: SuggestionProps) {
this.items = Array.isArray(props.items) ? props.items : [];
this.selectedIndex = 0;
this.render();
}
destroy() {
this.element.remove();
}
}
export const VariableMention = Mention.configure({
HTMLAttributes: {
class: 'variable-mention',
},
renderLabel({node}) {
return `{{${node.attrs.id}}}`;
},
suggestion: {
char: '{{',
items: ({query}) => {
const safeVariables = Array.isArray(availableVariables) ? availableVariables : [];
const allVariables = ['email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl', ...safeVariables];
const uniqueVariables = Array.from(new Set(allVariables)).filter(v => typeof v === 'string');
if (!query) {
return uniqueVariables.slice(0, 10);
}
const lowerQuery = query.toLowerCase();
return uniqueVariables.filter(item => item.toLowerCase().startsWith(lowerQuery)).slice(0, 10);
},
command: ({editor, range, props}: {editor: Editor; range: Range; props: {id: string | null}}) => {
// Delete the {{ trigger characters and insert the variable as plain text
if (!props.id) return;
editor.chain().focus().deleteRange(range).insertContent(`{{${props.id}}}`).run();
},
render: () => {
let component: VariableSuggestionList;
let popup: TippyInstance[];
let scrollHandler: (() => void) | null = null;
return {
onStart: (props: SuggestionProps) => {
component = new VariableSuggestionList(props);
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
theme: 'variable-suggestion',
plugins: [sticky],
sticky: 'reference',
popperOptions: {
strategy: 'fixed',
},
});
// Update position on scroll
scrollHandler = () => {
if (popup?.[0] && props.clientRect) {
popup[0].setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
}
};
// Find the scrolling editor container and add listener
const editorContainer = document.querySelector('.overflow-y-auto');
if (editorContainer) {
editorContainer.addEventListener('scroll', scrollHandler);
}
window.addEventListener('scroll', scrollHandler, true);
},
onUpdate(props: SuggestionProps) {
component?.update(props);
if (!props.clientRect) {
return;
}
popup?.[0]?.setProps({
getReferenceClientRect: props.clientRect as () => DOMRect,
});
},
onKeyDown(props: {event: KeyboardEvent}) {
if (props.event.key === 'Escape') {
popup?.[0]?.hide();
return true;
}
return component?.onKeyDown(props.event) || false;
},
onExit() {
// Clean up scroll listeners
if (scrollHandler) {
const editorContainer = document.querySelector('.overflow-y-auto');
if (editorContainer) {
editorContainer.removeEventListener('scroll', scrollHandler);
}
window.removeEventListener('scroll', scrollHandler, true);
}
popup?.[0]?.destroy();
component?.destroy();
},
};
},
},
});
@@ -0,0 +1,158 @@
import juice from 'juice';
/**
* Converts modern HTML from Tiptap to email-friendly HTML
* - Inlines CSS styles
* - Adds email-safe defaults
* - Preserves variable placeholders like {{email}}
*/
export function convertToEmailHtml(html: string): string {
// Wrap in email-safe container with basic styling
const wrappedHtml = `
<html>
<head>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #374151;
margin: 0;
padding: 0;
}
h1 {
font-size: 32px;
font-weight: 700;
margin: 0 0 16px 0;
color: #111827;
}
h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 12px 0;
color: #111827;
}
h3 {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
color: #111827;
}
p {
margin: 0 0 16px 0;
}
a {
color: #3B82F6;
text-decoration: underline;
}
ul, ol {
margin: 0 0 16px 0;
padding-left: 24px;
}
li {
margin-bottom: 8px;
}
blockquote {
margin: 0 0 16px 0;
padding-left: 16px;
border-left: 4px solid #E5E7EB;
color: #6B7280;
}
code {
background-color: #F3F4F6;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
strong {
font-weight: 600;
}
em {
font-style: italic;
}
img {
max-width: 100%;
height: auto;
display: block;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0 0 16px 0;
}
th, td {
border: 1px solid #E5E7EB;
padding: 8px 12px;
text-align: left;
}
th {
background-color: #F3F4F6;
font-weight: 600;
}
.variable-placeholder {
display: inline;
background-color: #DBEAFE;
color: #1E40AF;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 14px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3B82F6;
color: #FFFFFF;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
margin: 8px 0;
}
</style>
</head>
<body>
${html}
</body>
</html>
`;
// Inline CSS using juice
const inlined = juice(wrappedHtml, {
preserveMediaQueries: false,
preserveFontFaces: false,
removeStyleTags: true,
applyStyleTags: true,
});
// Extract just the body content
const bodyMatch = inlined.match(/<body[^>]*>([\s\S]*)<\/body>/i);
const bodyContent = bodyMatch && bodyMatch[1] ? bodyMatch[1].trim() : inlined;
// Clean up Tiptap-specific artifacts
const cleaned = bodyContent
.replace(/\sdata-pm-slice="[^"]*"/g, '')
.replace(/\sclass=""/g, '')
.replace(/\sstyle=""/g, '');
return cleaned;
}
/**
* Wraps text content in a button element for CTAs
*/
export function createButtonHtml(text: string, href: string, color = '#3B82F6'): string {
return `<a href="${href}" class="button" style="display: inline-block; padding: 12px 24px; background-color: ${color}; color: #FFFFFF; text-decoration: none; border-radius: 6px; font-weight: 600; margin: 8px 0;">${text}</a>`;
}
/**
* Converts plain HTML back to a format suitable for Tiptap
* Preserves structure but removes email-specific inline styles
*/
export function convertFromEmailHtml(html: string): string {
// Remove inline styles added by juice
const cleaned = html.replace(/\sstyle="[^"]*"/g, '');
// Preserve basic structure elements
return cleaned.trim();
}
@@ -0,0 +1,2 @@
export {EmailEditor} from './EmailEditor';
export {convertToEmailHtml, convertFromEmailHtml, createButtonHtml} from './emailHtmlConverter';