vboxes fix

This commit is contained in:
2026-03-19 14:28:09 +01:00
parent b7f7c1f7c0
commit 06dfbfcbc4
30 changed files with 4405 additions and 166 deletions
@@ -0,0 +1,517 @@
/**
* 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>
)
}