/** * RichTextEditor – editor di testo ricco in stile Roundcube. * * Usa TipTap (ProseMirror) con estensioni per: * - Formattazione testo: grassetto, corsivo, sottolineato, barrato * - Colore testo (palette + picker custom) * - Titoli H1/H2/H3 * - Elenchi puntati, numerati, citazioni * - Allineamento testo * - Collegamento ipertestuale * - Undo/Redo * - Rimuovi formattazione */ import { useEditor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import UnderlineExt from '@tiptap/extension-underline' import TextAlign from '@tiptap/extension-text-align' import Link from '@tiptap/extension-link' import Placeholder from '@tiptap/extension-placeholder' import { TextStyle } from '@tiptap/extension-text-style' import Color from '@tiptap/extension-color' import { useEffect, useCallback, useState, useRef } from 'react' import { Bold, Italic, Underline, Strikethrough, AlignLeft, AlignCenter, AlignRight, AlignJustify, List, ListOrdered, Quote, Link as LinkIcon, Link2Off, Undo2, Redo2, Baseline, Eraser, } from 'lucide-react' // ─── Tipi ──────────────────────────────────────────────────────────────────── interface RichTextEditorProps { value: string onChange: (html: string) => void placeholder?: string minHeight?: string } // ─── Palette colori ─────────────────────────────────────────────────────────── const COLOR_PALETTE = [ // Grigi '#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#ffffff', // Vivaci '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#9900ff', '#ff00ff', // Chiari '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc', // Standard '#ea4335', '#fbbc04', '#34a853', '#4285f4', '#9334e6', '#c2185b', '#e65100', '#00838f', ] // ─── Sub-componenti toolbar ─────────────────────────────────────────────────── interface ToolbarButtonProps { onClick: () => void isActive?: boolean disabled?: boolean title: string children: React.ReactNode } function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) { return ( ) } function ToolbarSeparator() { return
} // ─── Color Picker ───────────────────────────────────────────────────────────── interface ColorPickerProps { currentColor?: string onSelect: (color: string) => void onClose: () => void } function ColorPicker({ currentColor, onSelect, onClose }: ColorPickerProps) { const ref = useRef(null) useEffect(() => { const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { onClose() } } // Delay to avoid closing immediately on the button click that opened us const timer = setTimeout(() => document.addEventListener('mousedown', handler), 50) return () => { clearTimeout(timer) document.removeEventListener('mousedown', handler) } }, [onClose]) return (
Colore testo
{COLOR_PALETTE.map((color) => (
onSelect(e.target.value)} />
) } // ─── Link Dialog ────────────────────────────────────────────────────────────── interface LinkDialogProps { currentUrl?: string onConfirm: (url: string) => void onRemove: () => void onClose: () => void } function LinkDialog({ currentUrl, onConfirm, onRemove, onClose }: LinkDialogProps) { const [url, setUrl] = useState(currentUrl || 'https://') const ref = useRef(null) const inputRef = useRef(null) useEffect(() => { const timer = setTimeout(() => inputRef.current?.focus(), 50) const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) onClose() } setTimeout(() => document.addEventListener('mousedown', handler), 50) return () => { clearTimeout(timer) document.removeEventListener('mousedown', handler) } }, [onClose]) const confirm = useCallback(() => { if (url && url !== 'https://') { onConfirm(url.startsWith('http') ? url : `https://${url}`) } onClose() }, [url, onConfirm, onClose]) return (
{currentUrl ? 'Modifica collegamento' : 'Inserisci collegamento'}
setUrl(e.target.value)} placeholder="https://esempio.it" className="flex-1 text-sm border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-ring bg-background" onKeyDown={(e) => { if (e.key === 'Enter') confirm() if (e.key === 'Escape') onClose() }} /> {currentUrl && ( )}
) } // ─── Toolbar principale ─────────────────────────────────────────────────────── interface ToolbarProps { editor: ReturnType } function Toolbar({ editor }: ToolbarProps) { const [showColorPicker, setShowColorPicker] = useState(false) const [showLinkDialog, setShowLinkDialog] = useState(false) const colorRef = useRef(null) const linkRef = useRef(null) if (!editor) return null const currentColor = editor.getAttributes('textStyle').color as string | undefined const currentLinkUrl = editor.getAttributes('link').href as string | undefined return (
{/* Undo / Redo */} editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Annulla (Ctrl+Z)" > editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Ripeti (Ctrl+Y)" > {/* Formattazione di base */} editor.chain().focus().toggleBold().run()} isActive={editor.isActive('bold')} title="Grassetto (Ctrl+B)" > editor.chain().focus().toggleItalic().run()} isActive={editor.isActive('italic')} title="Corsivo (Ctrl+I)" > editor.chain().focus().toggleUnderline().run()} isActive={editor.isActive('underline')} title="Sottolineato (Ctrl+U)" > editor.chain().focus().toggleStrike().run()} isActive={editor.isActive('strike')} title="Barrato" > {/* Colore testo */}
setShowColorPicker((v) => !v)} isActive={showColorPicker} title="Colore testo" > {showColorPicker && ( { if (color) { editor.chain().focus().setColor(color).run() } else { editor.chain().focus().unsetColor().run() } }} onClose={() => setShowColorPicker(false)} /> )}
{/* Rimuovi formattazione */} editor.chain().focus().clearNodes().unsetAllMarks().run()} title="Rimuovi formattazione" > {/* Titoli */} editor.chain().focus().toggleHeading({ level: 1 }).run()} isActive={editor.isActive('heading', { level: 1 })} title="Titolo 1" > H1 editor.chain().focus().toggleHeading({ level: 2 }).run()} isActive={editor.isActive('heading', { level: 2 })} title="Titolo 2" > H2 editor.chain().focus().toggleHeading({ level: 3 }).run()} isActive={editor.isActive('heading', { level: 3 })} title="Titolo 3" > H3 {/* Elenchi */} editor.chain().focus().toggleBulletList().run()} isActive={editor.isActive('bulletList')} title="Elenco puntato" > editor.chain().focus().toggleOrderedList().run()} isActive={editor.isActive('orderedList')} title="Elenco numerato" > editor.chain().focus().toggleBlockquote().run()} isActive={editor.isActive('blockquote')} title="Citazione" > {/* Allineamento */} editor.chain().focus().setTextAlign('left').run()} isActive={editor.isActive({ textAlign: 'left' })} title="Allinea a sinistra" > editor.chain().focus().setTextAlign('center').run()} isActive={editor.isActive({ textAlign: 'center' })} title="Centra" > editor.chain().focus().setTextAlign('right').run()} isActive={editor.isActive({ textAlign: 'right' })} title="Allinea a destra" > editor.chain().focus().setTextAlign('justify').run()} isActive={editor.isActive({ textAlign: 'justify' })} title="Giustifica" > {/* Link */}
setShowLinkDialog((v) => !v)} isActive={editor.isActive('link') || showLinkDialog} title={editor.isActive('link') ? 'Modifica collegamento' : 'Inserisci collegamento'} > {showLinkDialog && ( editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() } onRemove={() => editor.chain().focus().unsetLink().run()} onClose={() => setShowLinkDialog(false)} /> )}
) } // ─── Componente principale ──────────────────────────────────────────────────── export function RichTextEditor({ value, onChange, placeholder = 'Scrivi il testo della PEC...', minHeight = '260px', }: RichTextEditorProps) { const editor = useEditor({ extensions: [ StarterKit.configure({ heading: { levels: [1, 2, 3] }, }), UnderlineExt, TextStyle, Color, TextAlign.configure({ types: ['heading', 'paragraph'], defaultAlignment: 'left', }), Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rte-link', rel: 'noopener noreferrer', }, }), Placeholder.configure({ placeholder }), ], content: value, onUpdate: ({ editor }) => { onChange(editor.getHTML()) }, editorProps: { attributes: { class: 'rte-content focus:outline-none', }, }, }) // Sync da valore esterno (es. modifica replyTo dopo il mount) useEffect(() => { if (editor && !editor.isDestroyed && value !== editor.getHTML()) { editor.commands.setContent(value, { emitUpdate: false }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]) return (
) }