Files
4ventures-client-portal/src/components/NoteRichEditor.tsx
4Ventures dd50251de2 feat: editor enriquecido en notas de Seguimiento
Barra tipo Docs (negrita, color, resaltado, listas, enlaces, tamaño) al crear notas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 17:35:42 +02:00

277 lines
7.7 KiB
TypeScript

'use client';
/**
* Editor enriquecido ligero para notas de Seguimiento
* (barra tipo Google Docs: negrita, color, resaltado, listas, enlaces).
*/
import React, { useCallback, useEffect, useRef } from 'react';
import {
Bold,
Italic,
Underline,
List,
ListOrdered,
Link as LinkIcon,
Highlighter,
Type,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
} from 'lucide-react';
type Props = {
value: string;
onChange: (html: string) => void;
placeholder?: string;
className?: string;
minHeight?: string;
};
function run(cmd: string, value?: string) {
try {
document.execCommand(cmd, false, value);
} catch {
/* ignore */
}
}
function ToolbarBtn({
title,
active,
onClick,
children,
}: {
title: string;
active?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => {
e.preventDefault(); // keep selection in editor
onClick();
}}
className={`inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-700 hover:bg-gray-100 ${
active ? 'bg-sky-100 text-sky-800' : ''
}`}
>
{children}
</button>
);
}
const TEXT_COLORS = [
{ label: 'Negro', value: '#111827' },
{ label: 'Rojo', value: '#DC2626' },
{ label: 'Naranja', value: '#EA580C' },
{ label: 'Ámbar', value: '#D97706' },
{ label: 'Verde', value: '#059669' },
{ label: 'Azul', value: '#2563EB' },
{ label: 'Violeta', value: '#7C3AED' },
];
const HIGHLIGHTS = [
{ label: 'Ninguno', value: 'transparent' },
{ label: 'Amarillo', value: '#FEF08A' },
{ label: 'Verde', value: '#BBF7D0' },
{ label: 'Azul', value: '#BFDBFE' },
{ label: 'Rosa', value: '#FECDD3' },
{ label: 'Naranja', value: '#FED7AA' },
];
export default function NoteRichEditor({
value,
onChange,
placeholder = 'Escribe la nota…',
className = '',
minHeight = '140px',
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const lastHtml = useRef(value);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (value !== lastHtml.current && el.innerHTML !== value) {
el.innerHTML = value || '';
lastHtml.current = value;
}
}, [value]);
const emit = useCallback(() => {
const el = ref.current;
if (!el) return;
const html = el.innerHTML;
lastHtml.current = html;
onChange(html);
}, [onChange]);
const apply = (fn: () => void) => {
ref.current?.focus();
fn();
emit();
};
const addLink = () => {
const url = window.prompt('URL del enlace', 'https://');
if (!url) return;
apply(() => run('createLink', url));
};
return (
<div
className={`rounded-xl border border-gray-200 bg-white overflow-hidden ${className}`}
>
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-gray-100 bg-gray-50">
<ToolbarBtn title="Negrita" onClick={() => apply(() => run('bold'))}>
<Bold className="w-3.5 h-3.5" />
</ToolbarBtn>
<ToolbarBtn title="Cursiva" onClick={() => apply(() => run('italic'))}>
<Italic className="w-3.5 h-3.5" />
</ToolbarBtn>
<ToolbarBtn
title="Subrayado"
onClick={() => apply(() => run('underline'))}
>
<Underline className="w-3.5 h-3.5" />
</ToolbarBtn>
<span className="w-px h-5 bg-gray-200 mx-1" />
<label className="inline-flex items-center gap-1 text-[11px] text-gray-600 px-1">
<Type className="w-3.5 h-3.5" />
<select
className="bg-transparent text-xs border-0 focus:ring-0 cursor-pointer"
defaultValue="3"
title="Tamaño"
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) =>
apply(() => run('fontSize', e.target.value))
}
>
<option value="2">Pequeño</option>
<option value="3">Normal</option>
<option value="4">Mediano</option>
<option value="5">Grande</option>
<option value="6">Muy grande</option>
</select>
</label>
<label className="inline-flex items-center gap-1 text-[11px] text-gray-600 px-1" title="Color de texto">
<span className="font-bold text-sm leading-none">A</span>
<select
className="bg-transparent text-xs border-0 focus:ring-0 cursor-pointer max-w-[88px]"
defaultValue="#111827"
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) =>
apply(() => run('foreColor', e.target.value))
}
>
{TEXT_COLORS.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
<label className="inline-flex items-center gap-1 text-[11px] text-gray-600 px-1" title="Resaltado">
<Highlighter className="w-3.5 h-3.5" />
<select
className="bg-transparent text-xs border-0 focus:ring-0 cursor-pointer max-w-[88px]"
defaultValue="transparent"
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) =>
apply(() => {
// hiliteColor works in most browsers; fallback backColor
run('hiliteColor', e.target.value);
run('backColor', e.target.value);
})
}
>
{HIGHLIGHTS.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
<span className="w-px h-5 bg-gray-200 mx-1" />
<ToolbarBtn
title="Lista"
onClick={() => apply(() => run('insertUnorderedList'))}
>
<List className="w-3.5 h-3.5" />
</ToolbarBtn>
<ToolbarBtn
title="Numerada"
onClick={() => apply(() => run('insertOrderedList'))}
>
<ListOrdered className="w-3.5 h-3.5" />
</ToolbarBtn>
<ToolbarBtn title="Enlace" onClick={addLink}>
<LinkIcon className="w-3.5 h-3.5" />
</ToolbarBtn>
<span className="w-px h-5 bg-gray-200 mx-1" />
<ToolbarBtn
title="Alinear izquierda"
onClick={() => apply(() => run('justifyLeft'))}
>
<AlignLeft className="w-3.5 h-3.5" />
</ToolbarBtn>
<ToolbarBtn
title="Centrar"
onClick={() => apply(() => run('justifyCenter'))}
>
<AlignCenter className="w-3.5 h-3.5" />
</ToolbarBtn>
<ToolbarBtn
title="Alinear derecha"
onClick={() => apply(() => run('justifyRight'))}
>
<AlignRight className="w-3.5 h-3.5" />
</ToolbarBtn>
<ToolbarBtn
title="Quitar formato"
onClick={() => apply(() => run('removeFormat'))}
>
<RemoveFormatting className="w-3.5 h-3.5" />
</ToolbarBtn>
</div>
<div
ref={ref}
contentEditable
role="textbox"
aria-multiline
suppressContentEditableWarning
data-placeholder={placeholder}
onInput={emit}
onBlur={emit}
className="px-3 py-2.5 text-sm text-gray-900 outline-none empty:before:content-[attr(data-placeholder)] empty:before:text-gray-400 focus:bg-white [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_a]:text-brand-600 [&_a]:underline"
style={{ minHeight }}
/>
</div>
);
}
/** True if HTML has visible text. */
export function richTextHasContent(html: string): boolean {
const text = html
.replace(/<br\s*\/?>/gi, '')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.trim();
return text.length > 0;
}