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>
This commit is contained in:
276
src/components/NoteRichEditor.tsx
Normal file
276
src/components/NoteRichEditor.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
'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(/ /gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim();
|
||||
return text.length > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user