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:
4Ventures
2026-09-24 17:35:42 +02:00
parent 9f30aa6820
commit dd50251de2
3 changed files with 301 additions and 12 deletions

View File

@@ -105,7 +105,17 @@ export async function POST(req: NextRequest) {
: typeof body.note === 'string'
? body.note.trim()
: '';
if (!text) {
const plain = text
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|h[1-6])>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/\s+/g, ' ')
.trim();
if (!plain) {
return NextResponse.json({ error: 'Texto de la nota requerido' }, { status: 400 });
}
const title =
@@ -123,7 +133,7 @@ export async function POST(req: NextRequest) {
entry_type: 'note',
external_id: `manual-${randomUUID()}`,
title,
summary: text.slice(0, 280),
summary: plain.slice(0, 280),
body_md: text,
occurred_at: occurredRaw,
synced_at: new Date().toISOString(),

View 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(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.trim();
return text.length > 0;
}

View File

@@ -14,6 +14,7 @@ import {
Send,
} from 'lucide-react';
import { PageHeader } from '@/components/ui';
import NoteRichEditor, { richTextHasContent } from '@/components/NoteRichEditor';
type Entry = {
id: string;
@@ -49,7 +50,9 @@ function typeTone(t: string) {
}
function looksLikeHtml(s: string) {
return /<\/?(?:ul|ol|li|p|a|strong|br)\b/i.test(s);
return /<\/?(?:ul|ol|li|p|a|strong|b|em|i|u|br|span|font|div|h[1-6])\b/i.test(
s
);
}
function escapeHtml(s: string) {
@@ -104,7 +107,7 @@ export default function SeguimientoBoard() {
}, [data, query]);
async function saveNote() {
if (!noteBody.trim()) return;
if (!richTextHasContent(noteBody)) return;
setNoteBusy(true);
setNoteMsg(null);
try {
@@ -113,7 +116,7 @@ export default function SeguimientoBoard() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: noteTitle.trim() || 'Nota de seguimiento',
body: noteBody.trim(),
body: noteBody,
occurredAt: new Date().toISOString(),
}),
});
@@ -209,8 +212,8 @@ export default function SeguimientoBoard() {
Nueva nota
</div>
<p className="text-xs text-gray-500">
Se guarda con fecha. El histórico también incluye entradas
sincronizadas desde Superhuman Docs (docs de seguimiento).
Formato enriquecido (negrita, color, resaltado, listas, enlaces). El
histórico también incluye entradas de Superhuman / Google Doc.
</p>
<input
value={noteTitle}
@@ -218,16 +221,16 @@ export default function SeguimientoBoard() {
placeholder="Título (opcional)"
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
/>
<textarea
<NoteRichEditor
value={noteBody}
onChange={(e) => setNoteBody(e.target.value)}
onChange={setNoteBody}
placeholder="Comentarios, hipótesis, bloqueos, siguientes pasos…"
className="w-full min-h-[120px] rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
minHeight="160px"
/>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
disabled={noteBusy || !noteBody.trim()}
disabled={noteBusy || !richTextHasContent(noteBody)}
onClick={() => void saveNote()}
className="inline-flex items-center gap-2 rounded-xl bg-brand-500 text-white px-4 py-2 text-sm font-semibold disabled:opacity-50"
>
@@ -332,7 +335,7 @@ export default function SeguimientoBoard() {
</div>
{open && e.body_md && (
<div
className="mt-3 text-sm text-gray-800 bg-gray-50 border border-gray-100 rounded-lg p-4 max-h-[36rem] overflow-y-auto [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 [&_a]:text-brand-600 [&_a]:underline [&_strong]:font-semibold [&_p]:my-1.5"
className="mt-3 text-sm text-gray-800 bg-gray-50 border border-gray-100 rounded-lg p-4 max-h-[36rem] overflow-y-auto [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 [&_a]:text-brand-600 [&_a]:underline [&_strong]:font-semibold [&_b]:font-semibold [&_em]:italic [&_i]:italic [&_u]:underline [&_p]:my-1.5"
onClick={(ev) => {
const t = ev.target as HTMLElement;
if (t.closest('a')) ev.stopPropagation();