Editor de Texto Enriquecido Estilo Notion – Requisitos de Implementación
Este documento se encuentra junto al código y debe mantenerse actualizado cada vez que cambie la implementación del editor.
Planteamiento del Problema
- El
<MDXEditorComponent>existente envuelve @mdxeditor/editor, lo que nos proporciona edición clásica de Markdown. - Los autores desean una experiencia similar a Notion, basada en bloques: comandos de barra diagonal, arrastrar para reordenar bloques, listas de tareas, tablas, incrustaciones, etc.
- El reemplazo debe integrarse limpiamente con los cargadores/acciones de React Router, continuar guardando borradores automáticamente y funcionar tanto en los flujos de creación como de edición.
Resumen de la Solución
- Reemplazar MDXEditor con TipTap + ProseMirror – un editor de código abierto, sin interfaz gráfica, extensible, utilizado por Craft, GitLab, ChatGPT y otros.
- Mantener un componente contenedor delgado (
NotionEditor.tsx) que oculte los detalles específicos de TipTap y exponga una API familiar (value,onChange). - Persistir JSON o Markdown (matriz de decisión a continuación) en la columna existente
blog_posts.content; los ayudantes de conversión mantienen la compatibilidad hacia atrás.
Paquetes para Añadir
pnpm --filter web add @tiptap/react @tiptap/starter-kit @tiptap/extension-placeholder \
@tiptap/extension-link @tiptap/extension-task-list @tiptap/extension-task-item \
@tiptap/extension-underline @tiptap/extension-table @tiptap/extension-table-row \
@tiptap/extension-table-cell @tiptap/extension-table-header \
@tiptap/extension-code-block-lowlight lowlight classnamesExtras opcionales (trabajo futuro): @hocuspocus/provider para sincronización en tiempo real, tiptap-extension-command para menú de barra diagonal, tiptap-extension-placeholder para IA.
Diseño de Archivos
apps/web/app/components/rich-text/
NotionEditor.tsx <-- contenedor reutilizable de TipTap (export default)
EditorBubbleMenu.tsx <-- barra de herramientas de formato en línea
EditorSlashMenu.tsx <-- menú de inserción de bloques
editor-extensions.ts <-- fábrica que devuelve extensiones comunes
serializer.ts <-- ayudantes JSON ⇆ Markdown (showdown / remark)NOTA: mantener la carpeta existente
mdx-editor/hasta que se complete la migración; luego marcar como obsoleta.
Lista de Verificación de Implementación
-
Crear componente editor ✅
- Usar
useEditorde@tiptap/react. - Cargar
StarterKit+ extensiones adicionales (definidas eneditor-extensions.ts). - Exponer props:
defaultContent,readOnly,onUpdate(devuelve tanto JSON como Markdown). - Aplicar clases Tailwind para el aspecto Notion (
prose,focus:outline-none, etc.).
- Usar
-
BubbleMenu & SlashMenu ✅
- BubbleMenu: negrita, cursiva, subrayado, enlace.
- SlashMenu:
/h1,/h2,/table,/code,/todo,/image. - Usar Headless UI / Radix
Commandpara lista accesible (a11y).
-
Integración de Autoguardado ✅
- En
triggerAutosave()reemplazarconst currentContent = editor.getJSON(); - Enviar como
JSON.stringify(currentContent).
- En
-
Cambios en Rutas (
posts/new&posts/[id]) ✅- Cambiar
<MDXEditorComponent>→<NotionEditor>. - El
<input name="content" />oculto ahora contiene la carga útil serializada. - Mantener la lógica existente de Sheet, diálogos, autoguardado.
- Cambiar
-
Decisión de Base de Datos ✅
| Opción | Pros | Contras |
|---|---|---|
Columna text, almacenar Markdown | Sin cambio de esquema | Se pierde la riqueza de bloques (tablas, incrustaciones) |
Columna jsonb, almacenar JSON de TipTap | Fidelidad completa, a prueba de futuro | Requiere migración y serialización en lectura para publicaciones antiguas |
Plan actual: migrar a jsonb pero entregar un convertidor de lectura/escritura de Markdown para evitar romper el blog público inmediatamente.
-
Script de Migración ✅
- Migración de Drizzle añadiendo una nueva columna
content_json jsonb(nulable). - Relleno:
content_json = markdownToTiptapJSON(content). - Eventualmente, eliminar la antigua columna
content.
- Migración de Drizzle añadiendo una nueva columna
-
Consideraciones de SSR y Compilación
- TipTap usa
window; envolver el hook en un guardiauseCliento importar de forma diferida dentro deuseEffectpara rutas SSR. - Continuar cargando de forma diferida para mantener pequeño el tamaño del paquete.
- TipTap usa
-
Accesibilidad y Atajos de Teclado
Cmd/Ctrl + b/i/upara marcas.Cmd/Ctrl + s→ abre el diálogo Guardar Borrador (ya existe).Cmd/Ctrl + Shift + p→ publicar.
-
Pruebas y QA
- Prueba unitaria de la fábrica de extensiones – devuelve la lista esperada.
- Cypress e2e: crear publicación, agregar bloques, autoguardar, refrescar – el borrador persiste.
- Regresión visual con Storybook (opcional).
-
Mejoras Futuras
- Cursores colaborativos con Y.js.
- Modo de comentarios y sugerencias.
- Comando de barra diagonal de IA para generación de contenido.
- Vistas previas de incrustaciones (Twitter, Loom, Figma, …).
Fragmento de Referencia – NotionEditor.tsx
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import TaskList from "@tiptap/extension-task-list";
import TaskItem from "@tiptap/extension-task-item";
import Link from "@tiptap/extension-link";
export function NotionEditor({ defaultContent, onUpdate }) {
const editor = useEditor({
extensions: [
StarterKit.configure({ codeBlock: false }),
Placeholder.configure({ placeholder: "Escribe / para bloques" }),
TaskList,
TaskItem,
Link.configure({ openOnClick: false })
],
content: defaultContent,
onUpdate: ({ editor }) => {
onUpdate?.({
json: editor.getJSON(),
markdown: editor.getHTML() // o serializador personalizado
});
}
});
return (
<div className="prose leading-relaxed max-w-none border rounded-md p-4 focus:outline-none">
<EditorContent editor={editor} />
</div>
);
}Cronograma de Migración
| Semana | Tarea |
|---|---|
| 1 | Estructurar componente editor y extensiones básicas |
| 2 | Integrar en la ruta de nueva publicación, mantener MDXEditor lado a lado como respaldo |
| 3 | Actualizar página de edición, habilitar conversión Markdown→JSON |
| 4 | Migración de BD, relleno, eliminar código del editor antiguo |
Mantenedor: @samishukri • Última actualización: 23/10/2026