import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import { Node } from '@tiptap/core';
import { Button } from '@/components/ui/button';
import {
Bold, Italic, List, ListOrdered, Quote, Link as LinkIcon,
Image as ImageIcon, Heading1, Heading2, Undo, Redo,
Maximize2, Minimize2, MousePointer, Square
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from '@/hooks/use-toast';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface RichTextEditorProps {
content: string;
onChange: (html: string) => void;
placeholder?: string;
className?: string;
}
// Custom Image extension with resize support
const ResizableImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: null,
parseHTML: element => element.getAttribute('width') || element.style.width?.replace('px', ''),
renderHTML: attributes => {
if (!attributes.width) return {};
return { width: attributes.width, style: `width: ${attributes.width}px` };
},
},
height: {
default: null,
parseHTML: element => element.getAttribute('height') || element.style.height?.replace('px', ''),
renderHTML: attributes => {
if (!attributes.height) return {};
return { height: attributes.height, style: `height: ${attributes.height}px` };
},
},
};
},
});
// Custom Button extension for email templates
const EmailButton = Node.create({
name: 'emailButton',
group: 'block',
addAttributes() {
return {
url: {
default: '#',
parseHTML: element => element.getAttribute('data-url') || '#',
renderHTML: attributes => ({
'data-url': attributes.url,
}),
},
text: {
default: 'Button',
parseHTML: element => element.textContent || 'Button',
renderHTML: attributes => ({}),
},
fullWidth: {
default: false,
parseHTML: element => element.classList.contains('btn-full'),
renderHTML: attributes => ({
class: attributes.fullWidth ? 'btn btn-full' : 'btn',
}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-email-button]',
},
];
},
renderHTML({ HTMLAttributes, node }) {
const { url, text, fullWidth } = node.attrs;
return [
'p',
{ style: 'margin-top: 20px; text-align: center;' },
[
'a',
{
href: url,
class: fullWidth ? 'btn btn-full' : 'btn',
'data-email-button': '',
style: `
display: inline-block;
background-color: #000;
color: #FFF !important;
padding: 14px 28px;
font-weight: 700;
text-transform: uppercase;
text-decoration: none !important;
font-size: 16px;
border: 2px solid #000;
box-shadow: 4px 4px 0px 0px #000000;
margin: 10px 0;
transition: all 0.1s;
text-align: center;
${fullWidth ? 'width: 100%; box-sizing: border-box;' : ''}
`,
},
text || 'Button',
],
];
},
addNodeView() {
return ({ node, editor }) => {
const dom = document.createElement('div');
dom.style.cssText = 'margin: 10px 0; border: 2px dashed #007acc; padding: 8px; border-radius: 4px; background: #f0f9ff;';
const button = document.createElement('a');
button.href = node.attrs.url;
button.textContent = node.attrs.text;
button.style.cssText = `
display: inline-block;
background-color: #000;
color: #FFF;
padding: 14px 28px;
font-weight: 700;
text-transform: uppercase;
text-decoration: none;
font-size: 16px;
border: 2px solid #000;
box-shadow: 4px 4px 0px 0px #000000;
cursor: pointer;
${node.attrs.fullWidth ? 'width: 100%; text-align: center; box-sizing: border-box;' : ''}
`;
dom.appendChild(button);
return {
dom,
destroy: () => {
dom.remove();
},
};
};
},
});
// Custom OTP Box extension
const OTPBox = Node.create({
name: 'otpBox',
group: 'block',
addAttributes() {
return {
code: {
default: '123-456',
parseHTML: element => element.getAttribute('data-code') || '123-456',
renderHTML: attributes => ({
'data-code': attributes.code,
}),
},
};
},
parseHTML() {
return [
{
tag: 'div[data-otp-box]',
},
];
},
renderHTML({ HTMLAttributes, node }) {
const { code } = node.attrs;
return [
'div',
{
'data-otp-box': '',
style: `
background-color: #F4F4F5;
border: 2px dashed #000;
padding: 20px;
text-align: center;
margin: 20px 0;
letter-spacing: 5px;
font-family: 'Courier New', Courier, monospace;
font-size: 32px;
font-weight: 700;
color: #000;
`,
},
code,
];
},
addNodeView() {
return ({ node, editor }) => {
const dom = document.createElement('div');
dom.style.cssText = 'margin: 10px 0; border: 2px dashed #007acc; padding: 8px; border-radius: 4px; background: #f0f9ff;';
dom.innerHTML = `
OTP Box: ${node.attrs.code}
${node.attrs.code}
`;
return {
dom,
destroy: () => {
dom.remove();
},
};
};
},
});
export function RichTextEditor({ content, onChange, placeholder = 'Tulis konten...', className }: RichTextEditorProps) {
const [uploading, setUploading] = useState(false);
const [selectedImage, setSelectedImage] = useState<{ src: string; width?: number; height?: number } | null>(null);
const [imageWidth, setImageWidth] = useState('');
const [imageHeight, setImageHeight] = useState('');
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-primary underline',
},
}),
ResizableImage.configure({
HTMLAttributes: {
class: 'max-w-full h-auto rounded-md cursor-pointer',
},
}),
EmailButton,
OTPBox,
Placeholder.configure({
placeholder,
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
onSelectionUpdate: ({ editor }) => {
// Check if an image is selected
const { selection } = editor.state;
const node = editor.state.doc.nodeAt(selection.from);
if (node?.type.name === 'image') {
setSelectedImage({
src: node.attrs.src,
width: node.attrs.width,
height: node.attrs.height,
});
setImageWidth(node.attrs.width?.toString() || '');
setImageHeight(node.attrs.height?.toString() || '');
} else {
setSelectedImage(null);
}
},
});
// Sync content when it changes externally
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content || '');
}
}, [content, editor]);
const addLink = useCallback(() => {
if (!editor) return;
const url = window.prompt('Masukkan URL:');
if (url) {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}
}, [editor]);
const addButton = useCallback(() => {
if (!editor) return;
const text = window.prompt('Teks Button:') || 'Button';
const url = window.prompt('URL Button:') || '#';
const fullWidth = window.confirm('Gunakan lebar penuh?');
editor.chain().focus().insertContent({
type: 'emailButton',
attrs: {
text,
url,
fullWidth,
},
}).run();
}, [editor]);
const addOTPBox = useCallback(() => {
if (!editor) return;
const code = window.prompt('Kode OTP (contoh: 123-456):') || '123-456';
editor.chain().focus().insertContent({
type: 'otpBox',
attrs: {
code,
},
}).run();
}, [editor]);
const uploadImageToStorage = async (file: File): Promise => {
try {
const fileExt = file.name.split('.').pop();
const fileName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${fileExt}`;
const filePath = `editor-images/${fileName}`;
const { data, error } = await supabase.storage
.from('content')
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
});
if (error) {
console.error('Storage upload error:', error);
// Fall back to base64 if storage fails
return null;
}
const { data: urlData } = supabase.storage.from('content').getPublicUrl(filePath);
return urlData.publicUrl;
} catch (err) {
console.error('Upload error:', err);
return null;
}
};
const convertToDataUrl = (file: File): Promise => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const handleImageUpload = useCallback(async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (!file || !editor) return;
setUploading(true);
// Try to upload to storage first
let imageUrl = await uploadImageToStorage(file);
// Fall back to base64 if storage upload fails
if (!imageUrl) {
toast({
title: 'Info',
description: 'Menggunakan penyimpanan lokal untuk gambar',
});
imageUrl = await convertToDataUrl(file);
}
editor.chain().focus().setImage({ src: imageUrl }).run();
setUploading(false);
// Reset input
e.target.value = '';
}, [editor]);
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items || !editor) return;
for (const item of Array.from(items)) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (!file) continue;
setUploading(true);
let imageUrl = await uploadImageToStorage(file);
if (!imageUrl) {
imageUrl = await convertToDataUrl(file);
}
editor.chain().focus().setImage({ src: imageUrl }).run();
setUploading(false);
break;
}
}
}, [editor]);
const updateImageSize = useCallback(() => {
if (!editor || !selectedImage) return;
const width = imageWidth ? parseInt(imageWidth) : null;
const height = imageHeight ? parseInt(imageHeight) : null;
editor.chain().focus().updateAttributes('image', { width, height }).run();
toast({ title: 'Berhasil', description: 'Ukuran gambar diperbarui' });
}, [editor, selectedImage, imageWidth, imageHeight]);
const setPresetSize = useCallback((size: 'small' | 'medium' | 'large' | 'full') => {
if (!editor) return;
const sizes = {
small: { width: 200, height: null },
medium: { width: 400, height: null },
large: { width: 600, height: null },
full: { width: null, height: null },
};
const { width, height } = sizes[size];
editor.chain().focus().updateAttributes('image', { width, height }).run();
setImageWidth(width?.toString() || '');
setImageHeight(height?.toString() || '');
}, [editor]);
if (!editor) return null;
return (
{/* Email Components Separator */}
{/* Email Component Buttons */}
{/* Image Upload Separator */}
{/* Image resize popover */}
{selectedImage && (
Ukuran Gambar
)}
{uploading && (
Mengunggah gambar...
)}
);
}