CreaRack-SL

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: PUT con {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í.

Tres secciones colapsables:

  1. Anclados (pinned=1, no archived) — siempre arriba si hay alguno.
  2. Notas (pinned=0, no archived) — sección principal.
  3. 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-markdown storage (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: false en 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, TableHeader solo 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 el default). Solución: import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'.
  • @tiptap/core peer dep no se instala automático: pnpm no autoescaló el peer de starter-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 #etiqueta para 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]]