Cuaderno · bloc de notas markdown personal (PR3 plan workspace tools s52)
Qué es
Cuaderno es la tercera tool del plan workspace tools s52 (PR3 de 4). Bloc de notas markdown personal — cada miembro del staff (Edu, Dani, Txell) ve solo las suyas. Vive en /tools/notebook.
Sustituye apuntes sueltos en gist/markdown local + Notion/Obsidian externos por un editor WYSIWYG integrado en el workspace, con auto-save, búsqueda, anclar/archivar y export a MD/HTML.
Editor
Stack TipTap 3.22 con extensiones:
StarterKit— bold/italic/strike/code/heading 1-3/lists/blockquote/codeBlock/HR.Link— autolink + abrir en pestaña nueva.TaskList + TaskItem(nested) — checkboxes interactivos.Table + TableRow + TableCell + TableHeader— tablas resizables.Placeholder— “Escribe / para abrir el menú…” en líneas vacías.Markdown(tiptap-markdown) — usado solo para export.Suggestion— slash menu ’/’ con 10 comandos.
Slash menu ’/’
Escribir / en una línea vacía abre el menú con 10 comandos agrupados:
Bloques: Heading 1/2/3, Lista con viñetas, Lista numerada, Lista de tareas, Bloque de código, Cita, Tabla 3×3, Línea horizontal.
Navegación con ↑/↓, Enter selecciona, Escape cierra. Filtra escribiendo (/head filtra a Heading 1/2/3).
Implementación: slashCommands.ts exporta createSlashExtension(updater) que retorna una Extension TipTap. Internamente usa @tiptap/suggestion. El estado del menú (open, query, position, selectedIndex, filtered) lo mantiene el componente React vía el updater callback. El render del dropdown lo hace el componente, no la extension — más control sobre estilos y aria.
Auto-save
Debounce 1s separado para título y contenido (timers distintos en useRef).
- Título:
PUT /api/notebook/<id>con{title}→ se loguea como activity (significant change). - Contenido:
PUTcon{content, word_count}→ NO se loguea activity (cada keystroke debouced sería ruido).
Solo title, pinned y archived disparan log de actividad. Word count se calcula en cliente: text.trim().split(/\s+/).length.
Indicador visual en toolbar: “Guardando…” / “Auto-guardado ●” (verde).
Storage
Content guardado como JSON nativo de TipTap (editor.getJSON() → JSON.stringify → columna content TEXT).
Razón: lossless, round-trip exacto. El export a markdown/HTML se hace en runtime con tiptap-markdown + editor.getHTML(). Trade-off aceptado: si en futuro se quiere migrar de TipTap, hay que serializar a markdown todo el corpus en una migration única.
Schema D1 (migration 0022)
CREATE TABLE notebook_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id TEXT NOT NULL, -- email CF Access
title TEXT NOT NULL DEFAULT 'Sin título',
content TEXT NOT NULL DEFAULT '{}', -- JSON TipTap
word_count INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
archived_at TEXT, -- soft-delete (sigue accesible en sidebar)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_notebook_owner ON notebook_notes(owner_id, archived_at, pinned DESC, updated_at DESC);
CREATE INDEX idx_notebook_archived ON notebook_notes(archived_at);
Index compuesto sirve la query principal (lista del sidebar) sin filesort.
Scope user enforced en endpoints
CRÍTICO: el filtro WHERE owner_id = ? está en todos los endpoints (GET list, GET single, PUT, DELETE). Sin él, las notas de Dani aparecerían a Edu.
actorFrom(request) lee cf-access-authenticated-user-email. Si CF Access falla y el header no llega (caso web fallback), el owner sería ‘web’ literal — sus notas no se mezclarían con las del staff pero tampoco serían recuperables. CF Access es load-bearing para correctness aquí.
Sidebar (lista)
Tres secciones colapsables:
- Anclados (pinned=1, no archived) — siempre arriba si hay alguno.
- Notas (pinned=0, no archived) — sección principal.
- Archivadas (archived_at != NULL) — colapsada por defecto.
Búsqueda libre filtra por title. Click en una fila → carga GET /api/notebook/<id> y abre editor.
Decisión: archivadas no se ocultan completamente. Siguen visibles en su sección colapsable. Filosofía: archivar ≠ borrar. Para borrar realmente hay un botón “Eliminar nota” rojo abajo del editor (DELETE hard).
Export
Modal con dos tabs:
- Markdown — vía
tiptap-markdownstorage (editor.storage.markdown.getMarkdown()). - HTML —
editor.getHTML()envuelto en doctype + estilos básicos para que el archivo standalone sea legible.
Acciones: Copiar al portapapeles · Descargar (<note-title>.md o .html).
No PDF: la lib pdfkit/jspdf añadiría 200+ KB y no convence el render. Decisión Edu: descargar HTML y usar la impresora PDF del sistema (Ctrl+P).
Decisiones de diseño
- Title como
<input>, no como heading editable dentro del editor. Más simple para auto-save independiente, mejor UX (no mete title en el JSON del content). key={activeNote.id}en<NoteEditor>para forzar remontaje al cambiar de nota — evita conflictos con el state interno de TipTap.immediatelyRender: falseen useEditor — necesario para SSR Astro (sin esto da error hydration).- No collaboration. Yjs/Hocuspocus añade ~150 KB + websocket. Si en futuro se quiere co-edición, está la base TipTap pero no merece el coste para 3 personas con notas personales.
Footguns documentados
- TipTap 3 named exports:
Table,TableRow,TableCell,TableHeadersolo se exportan como named desde@tiptap/extension-table. Los paquetes individuales (@tiptap/extension-table-row, etc) re-exportan desde ahí pero los imports default fallaron en build (rollup no resuelve eldefault). Solución:import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'. @tiptap/corepeer dep no se instala automático: pnpm no autoescaló el peer destarter-kit. Hay que añadirlo explícito al package.json o vite no resuelve el bundle de@tiptap/suggestion.- NAV_TOOLS hardcoded en 2 sitios:
navigation.ts+Sidebar.tsx. Footgun heredado de Quick Links.
Próximos pasos opcionales
No bloqueantes, sólo si emerge necesidad:
- Backlinks
[[wikilink]]que enlacen entre notas del propio cuaderno. - Mention
@persona(vinculado a Direcciones) — útil para minutas. - Hashtags
#etiquetapara filtrar después. - Reorden manual del sidebar (drag-and-drop).
Véase también
- [[feature—workspace—quick-links]]
- [[feature—workspace—directions]]
- [[feature—workspace—tools-hub]]