"use client"; import React, { useEffect, useCallback } from "react"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Underline from "@tiptap/extension-underline"; import Link from "@tiptap/extension-link"; import TextAlign from "@tiptap/extension-text-align"; import { TextStyle } from "@tiptap/extension-text-style"; import Color from "@tiptap/extension-color"; import { ResizableImage } from "@/components/email/resizable-image"; import Placeholder from "@tiptap/extension-placeholder"; import { cn } from "@/lib/utils"; import { Bold, Italic, Underline as UnderlineIcon, Strikethrough, List, ListOrdered, AlignLeft, AlignCenter, AlignRight, Link as LinkIcon, Undo, Redo, Quote, Code, RemoveFormatting, Heading1, Heading2, } from "lucide-react"; interface RichTextEditorProps { content: string; onChange: (html: string) => void; onImageUpload?: (file: File) => Promise; placeholder?: string; className?: string; hasError?: boolean; } function ToolbarButton({ active, onClick, children, title, disabled, }: { active?: boolean; onClick: () => void; children: React.ReactNode; title: string; disabled?: boolean; }) { return ( ); } function ToolbarSeparator() { return
; } export function RichTextEditor({ content, onChange, onImageUpload, placeholder, className, hasError, }: RichTextEditorProps) { const onImageUploadRef = React.useRef(onImageUpload); onImageUploadRef.current = onImageUpload; const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2] }, }), Underline, Link.configure({ openOnClick: false, HTMLAttributes: { rel: "noopener noreferrer nofollow" }, }), TextAlign.configure({ types: ["heading", "paragraph"], }), TextStyle, Color, ResizableImage, Placeholder.configure({ placeholder, }), ], content, editorProps: { attributes: { class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground", }, handleDrop: (view, event) => { const upload = onImageUploadRef.current; if (!upload || !event.dataTransfer?.files?.length) return false; const imageFiles = Array.from(event.dataTransfer.files).filter(f => f.type.startsWith("image/") ); if (imageFiles.length === 0) return false; event.preventDefault(); for (const file of imageFiles) { upload(file).then((url) => { if (url) { const { state } = view; const pos = view.posAtCoords({ left: event.clientX, top: event.clientY }); const node = state.schema.nodes.image.create({ src: url, alt: file.name }); const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node); view.dispatch(tr); } }); } return true; }, handlePaste: (view, event) => { const upload = onImageUploadRef.current; if (!upload || !event.clipboardData?.files?.length) return false; const imageFiles = Array.from(event.clipboardData.files).filter(f => f.type.startsWith("image/") ); if (imageFiles.length === 0) return false; event.preventDefault(); for (const file of imageFiles) { upload(file).then((url) => { if (url) { const { state } = view; const node = state.schema.nodes.image.create({ src: url, alt: file.name }); const tr = state.tr.replaceSelectionWith(node); view.dispatch(tr); } }); } return true; }, }, onUpdate: ({ editor }) => { onChange(editor.getHTML()); }, immediatelyRender: false, }); // Sync external content changes (e.g. template application) useEffect(() => { if (editor && content !== editor.getHTML()) { editor.commands.setContent(content, { emitUpdate: false }); } }, [content, editor]); const addLink = useCallback(() => { if (!editor) return; const previousUrl = editor.getAttributes("link").href; const url = window.prompt("URL", previousUrl); if (url === null) return; if (url === "") { editor.chain().focus().extendMarkRange("link").unsetLink().run(); return; } editor .chain() .focus() .extendMarkRange("link") .setLink({ href: url }) .run(); }, [editor]); if (!editor) { return (
); } return (
{/* Toolbar */}
editor.chain().focus().toggleBold().run()} title="Bold" > editor.chain().focus().toggleItalic().run()} title="Italic" > editor.chain().focus().toggleUnderline().run()} title="Underline" > editor.chain().focus().toggleStrike().run()} title="Strikethrough" > editor.chain().focus().toggleHeading({ level: 1 }).run()} title="Heading 1" > editor.chain().focus().toggleHeading({ level: 2 }).run()} title="Heading 2" > editor.chain().focus().toggleBulletList().run()} title="Bullet List" > editor.chain().focus().toggleOrderedList().run()} title="Ordered List" > editor.chain().focus().toggleBlockquote().run()} title="Quote" > editor.chain().focus().toggleCodeBlock().run()} title="Code Block" > editor.chain().focus().setTextAlign("left").run()} title="Align Left" > editor.chain().focus().setTextAlign("center").run()} title="Align Center" > editor.chain().focus().setTextAlign("right").run()} title="Align Right" > editor.chain().focus().clearNodes().unsetAllMarks().run()} title="Clear Formatting" > editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo" > editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo" >
{/* Editor */}
); }