Initial push of Plunk Next
This commit is contained in:
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';
|
||||
Reference in New Issue
Block a user