/**
* 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 (
)
}
// ─── 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 (
)
}