Files
PecHub/frontend/src/components/RichTextEditor/RichTextEditor.tsx
T
2026-03-19 14:28:09 +01:00

518 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (
<button
type="button"
title={title}
disabled={disabled}
onClick={onClick}
className={[
'flex items-center justify-center w-7 h-7 rounded text-sm transition-colors select-none',
isActive
? 'bg-primary/15 text-primary'
: 'text-foreground hover:bg-muted',
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer',
].join(' ')}
>
{children}
</button>
)
}
function ToolbarSeparator() {
return <div className="w-px h-5 bg-border mx-0.5 flex-shrink-0" />
}
// ─── Color Picker ─────────────────────────────────────────────────────────────
interface ColorPickerProps {
currentColor?: string
onSelect: (color: string) => void
onClose: () => void
}
function ColorPicker({ currentColor, onSelect, onClose }: ColorPickerProps) {
const ref = useRef<HTMLDivElement>(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 (
<div
ref={ref}
className="absolute z-50 top-full left-0 mt-1 p-2.5 bg-background border rounded-lg shadow-xl min-w-max"
>
<div className="text-xs text-muted-foreground mb-1.5 font-medium">Colore testo</div>
<div className="grid grid-cols-8 gap-1 mb-2">
{COLOR_PALETTE.map((color) => (
<button
key={color}
type="button"
className={[
'w-5 h-5 rounded-sm border transition-transform hover:scale-110',
currentColor === color ? 'ring-2 ring-primary ring-offset-1' : 'border-border/40',
].join(' ')}
style={{ backgroundColor: color }}
onClick={() => { onSelect(color); onClose() }}
title={color}
/>
))}
</div>
<div className="flex items-center gap-2 pt-1.5 border-t">
<label className="text-xs text-muted-foreground">Personalizzato:</label>
<input
type="color"
className="w-7 h-6 rounded cursor-pointer border border-border"
defaultValue={currentColor || '#000000'}
onChange={(e) => onSelect(e.target.value)}
/>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground underline ml-auto"
onClick={() => { onSelect(''); onClose() }}
>
Rimuovi
</button>
</div>
</div>
)
}
// ─── 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<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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 (
<div
ref={ref}
className="absolute z-50 top-full left-0 mt-1 p-3 bg-background border rounded-lg shadow-xl w-80"
>
<div className="text-xs text-muted-foreground mb-2 font-medium">
{currentUrl ? 'Modifica collegamento' : 'Inserisci collegamento'}
</div>
<div className="flex gap-2">
<input
ref={inputRef}
type="url"
value={url}
onChange={(e) => 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()
}}
/>
<button
type="button"
className="px-3 py-1.5 bg-primary text-primary-foreground rounded text-sm font-medium hover:bg-primary/90"
onClick={confirm}
>
OK
</button>
{currentUrl && (
<button
type="button"
className="p-1.5 bg-destructive/10 text-destructive rounded hover:bg-destructive/20"
onClick={() => { onRemove(); onClose() }}
title="Rimuovi collegamento"
>
<Link2Off className="h-4 w-4" />
</button>
)}
</div>
</div>
)
}
// ─── Toolbar principale ───────────────────────────────────────────────────────
interface ToolbarProps {
editor: ReturnType<typeof useEditor>
}
function Toolbar({ editor }: ToolbarProps) {
const [showColorPicker, setShowColorPicker] = useState(false)
const [showLinkDialog, setShowLinkDialog] = useState(false)
const colorRef = useRef<HTMLDivElement>(null)
const linkRef = useRef<HTMLDivElement>(null)
if (!editor) return null
const currentColor = editor.getAttributes('textStyle').color as string | undefined
const currentLinkUrl = editor.getAttributes('link').href as string | undefined
return (
<div className="flex flex-wrap items-center gap-0.5 p-1.5 bg-muted/40 border-b overflow-x-auto">
{/* Undo / Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Annulla (Ctrl+Z)"
>
<Undo2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Ripeti (Ctrl+Y)"
>
<Redo2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Formattazione di base */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Grassetto (Ctrl+B)"
>
<Bold className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Corsivo (Ctrl+I)"
>
<Italic className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')}
title="Sottolineato (Ctrl+U)"
>
<Underline className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title="Barrato"
>
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Colore testo */}
<div className="relative" ref={colorRef}>
<ToolbarButton
onClick={() => setShowColorPicker((v) => !v)}
isActive={showColorPicker}
title="Colore testo"
>
<span className="flex flex-col items-center leading-none">
<Baseline className="h-3 w-3" />
<span
className="block h-[3px] w-4 rounded-sm mt-0.5"
style={{ backgroundColor: currentColor || '#000000' }}
/>
</span>
</ToolbarButton>
{showColorPicker && (
<ColorPicker
currentColor={currentColor}
onSelect={(color) => {
if (color) {
editor.chain().focus().setColor(color).run()
} else {
editor.chain().focus().unsetColor().run()
}
}}
onClose={() => setShowColorPicker(false)}
/>
)}
</div>
{/* Rimuovi formattazione */}
<ToolbarButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Rimuovi formattazione"
>
<Eraser className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Titoli */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title="Titolo 1"
>
<span className="text-[11px] font-bold leading-none">H1</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Titolo 2"
>
<span className="text-[11px] font-bold leading-none">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Titolo 3"
>
<span className="text-[11px] font-bold leading-none">H3</span>
</ToolbarButton>
<ToolbarSeparator />
{/* Elenchi */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Elenco puntato"
>
<List className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Elenco numerato"
>
<ListOrdered className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Citazione"
>
<Quote className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Allineamento */}
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
isActive={editor.isActive({ textAlign: 'left' })}
title="Allinea a sinistra"
>
<AlignLeft className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })}
title="Centra"
>
<AlignCenter className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
isActive={editor.isActive({ textAlign: 'right' })}
title="Allinea a destra"
>
<AlignRight className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
isActive={editor.isActive({ textAlign: 'justify' })}
title="Giustifica"
>
<AlignJustify className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarSeparator />
{/* Link */}
<div className="relative" ref={linkRef}>
<ToolbarButton
onClick={() => setShowLinkDialog((v) => !v)}
isActive={editor.isActive('link') || showLinkDialog}
title={editor.isActive('link') ? 'Modifica collegamento' : 'Inserisci collegamento'}
>
<LinkIcon className="h-3.5 w-3.5" />
</ToolbarButton>
{showLinkDialog && (
<LinkDialog
currentUrl={currentLinkUrl}
onConfirm={(url) =>
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}
onRemove={() => editor.chain().focus().unsetLink().run()}
onClose={() => setShowLinkDialog(false)}
/>
)}
</div>
</div>
)
}
// ─── 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 (
<div className="border rounded-md overflow-hidden focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 bg-background">
<Toolbar editor={editor} />
<EditorContent
editor={editor}
style={{ minHeight }}
className="px-4 py-3 overflow-y-auto"
/>
</div>
)
}