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:
@@ -105,7 +105,17 @@ export async function POST(req: NextRequest) {
|
|||||||
: typeof body.note === 'string'
|
: typeof body.note === 'string'
|
||||||
? body.note.trim()
|
? 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(/ /gi, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (!plain) {
|
||||||
return NextResponse.json({ error: 'Texto de la nota requerido' }, { status: 400 });
|
return NextResponse.json({ error: 'Texto de la nota requerido' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const title =
|
const title =
|
||||||
@@ -123,7 +133,7 @@ export async function POST(req: NextRequest) {
|
|||||||
entry_type: 'note',
|
entry_type: 'note',
|
||||||
external_id: `manual-${randomUUID()}`,
|
external_id: `manual-${randomUUID()}`,
|
||||||
title,
|
title,
|
||||||
summary: text.slice(0, 280),
|
summary: plain.slice(0, 280),
|
||||||
body_md: text,
|
body_md: text,
|
||||||
occurred_at: occurredRaw,
|
occurred_at: occurredRaw,
|
||||||
synced_at: new Date().toISOString(),
|
synced_at: new Date().toISOString(),
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader } from '@/components/ui';
|
||||||
|
import NoteRichEditor, { richTextHasContent } from '@/components/NoteRichEditor';
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -49,7 +50,9 @@ function typeTone(t: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function looksLikeHtml(s: 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) {
|
function escapeHtml(s: string) {
|
||||||
@@ -104,7 +107,7 @@ export default function SeguimientoBoard() {
|
|||||||
}, [data, query]);
|
}, [data, query]);
|
||||||
|
|
||||||
async function saveNote() {
|
async function saveNote() {
|
||||||
if (!noteBody.trim()) return;
|
if (!richTextHasContent(noteBody)) return;
|
||||||
setNoteBusy(true);
|
setNoteBusy(true);
|
||||||
setNoteMsg(null);
|
setNoteMsg(null);
|
||||||
try {
|
try {
|
||||||
@@ -113,7 +116,7 @@ export default function SeguimientoBoard() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: noteTitle.trim() || 'Nota de seguimiento',
|
title: noteTitle.trim() || 'Nota de seguimiento',
|
||||||
body: noteBody.trim(),
|
body: noteBody,
|
||||||
occurredAt: new Date().toISOString(),
|
occurredAt: new Date().toISOString(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -209,8 +212,8 @@ export default function SeguimientoBoard() {
|
|||||||
Nueva nota
|
Nueva nota
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Se guarda con fecha. El histórico también incluye entradas
|
Formato enriquecido (negrita, color, resaltado, listas, enlaces). El
|
||||||
sincronizadas desde Superhuman Docs (docs de seguimiento).
|
histórico también incluye entradas de Superhuman / Google Doc.
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
value={noteTitle}
|
value={noteTitle}
|
||||||
@@ -218,16 +221,16 @@ export default function SeguimientoBoard() {
|
|||||||
placeholder="Título (opcional)"
|
placeholder="Título (opcional)"
|
||||||
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
|
className="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<NoteRichEditor
|
||||||
value={noteBody}
|
value={noteBody}
|
||||||
onChange={(e) => setNoteBody(e.target.value)}
|
onChange={setNoteBody}
|
||||||
placeholder="Comentarios, hipótesis, bloqueos, siguientes pasos…"
|
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">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={noteBusy || !noteBody.trim()}
|
disabled={noteBusy || !richTextHasContent(noteBody)}
|
||||||
onClick={() => void saveNote()}
|
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"
|
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>
|
</div>
|
||||||
{open && e.body_md && (
|
{open && e.body_md && (
|
||||||
<div
|
<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) => {
|
onClick={(ev) => {
|
||||||
const t = ev.target as HTMLElement;
|
const t = ev.target as HTMLElement;
|
||||||
if (t.closest('a')) ev.stopPropagation();
|
if (t.closest('a')) ev.stopPropagation();
|
||||||
|
|||||||
Reference in New Issue
Block a user