Cuaderno: persistencia de cursor y scroll por nota en localStorage
Cuaderno: persistencia de cursor y scroll por nota en localStorage
Contexto y problema
El módulo Cuaderno de CreaRack Workspace usa CodeMirror como editor embebido. CodeMirror se reinstancia cada vez que el usuario navega entre notas (el efecto de React se destruye y recrea al cambiar note.id). Esto provocaba que, al volver a una nota, el editor apareciera siempre en la línea 1, aunque el usuario la hubiera dejado en la línea 50.
Reportado por @Esquembri (Edu): «al cambiar de nota y volver, el editor aparece en la línea 1 aunque la había dejado en la 50».
Solución implementada
Se persiste por nota un objeto NoteScrollState en localStorage con la clave:
workspace.notebook.scrollState.<noteId>
Estructura del estado
interface NoteScrollState {
cursorPos: number; // posición del cursor (head de la selection principal)
scrollTop: number; // píxeles de scroll vertical del scrollDOM de CodeMirror
}
Flujo de restauración (carga de nota)
- Se llama a
loadScrollState(note.id)antes de crear elEditorView. - El
cursorPosse clampea a[0, doc.length]para manejar el caso en que el contenido haya cambiado en otro dispositivo sin sincronización. - El cursor se inyecta en
EditorState.create()viaselection: { anchor, head }. - El
scrollTopse restaura después del montaje usando un doblerequestAnimationFrame, que garantiza que el DOM ya está medido antes de asignar el valor.
Flujo de guardado (cambios durante la edición)
Se usan dos listeners independientes porque CodeMirror no dispara updateListener en scroll puro (sin cambio de selection):
| Listener | Evento capturado | Cuándo guarda |
|---|---|---|
EditorView.updateListener | selectionSet o docChanged | Debounced 300 ms |
scrollDOM.addEventListener('scroll') | Scroll nativo del DOM | Debounced 300 ms |
Ambos comparten scrollTimer para evitar disparos dobles.
Guardado al desmontar
Al destruir el EditorView (cambio de nota o cierre), se cancela el debounce pendiente y se ejecuta un guardado inmediato para no perder la última posición:
// cleanup del useEffect
if (scrollTimer.current) {
clearTimeout(scrollTimer.current);
saveScrollState(note.id, {
cursorPos: view.state.selection.main.head,
scrollTop: view.scrollDOM.scrollTop,
});
}
view.scrollDOM.removeEventListener('scroll', onScroll);
view.destroy();
Trade-offs y decisiones conscientes
| Aspecto | Decisión |
|---|---|
| Sincronización multi-dispositivo | Solo localStorage del navegador actual. No sincroniza entre dispositivos. Decisión consciente, mismo patrón que viewMode. |
| Contenido modificado en otro dispositivo | El cursorPos se clampea al doc.length actual para evitar errores de CodeMirror, aunque la posición restaurada puede no ser la “correcta” semánticamente. |
| Dos listeners por editor | Necesario porque updateListener no dispara en scroll puro. El listener nativo cubre ese hueco. |
| Debounce de 300 ms | Equilibrio entre frecuencia de escritura a localStorage y precisión. Se definió como SCROLL_DEBOUNCE_MS para ser diferenciado del AUTOSAVE_DEBOUNCE_MS (1 000 ms). |
| Clave de localStorage | Prefijo workspace.notebook.scrollState. es consistente con el patrón de namespacing existente en el proyecto. |
Archivos afectados
| Archivo | Cambio |
|---|---|
src/components/notebook/NoteEditor.tsx | +~90 LOC: helpers scrollKey, loadScrollState, saveScrollState; nueva interfaz NoteScrollState; lógica de restauración y guardado en el useEffect principal. |
Véase también
- [[workspace—que-es-workspace]]