mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
/**
|
||
* 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>
|
||
)
|
||
}
|