mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 20:55:41 +02:00
vboxes fix
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user