CreaRack-SL

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)

  1. Se llama a loadScrollState(note.id) antes de crear el EditorView.
  2. El cursorPos se clampea a [0, doc.length] para manejar el caso en que el contenido haya cambiado en otro dispositivo sin sincronización.
  3. El cursor se inyecta en EditorState.create() via selection: { anchor, head }.
  4. El scrollTop se restaura después del montaje usando un doble requestAnimationFrame, 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):

ListenerEvento capturadoCuándo guarda
EditorView.updateListenerselectionSet o docChangedDebounced 300 ms
scrollDOM.addEventListener('scroll')Scroll nativo del DOMDebounced 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

AspectoDecisión
Sincronización multi-dispositivoSolo localStorage del navegador actual. No sincroniza entre dispositivos. Decisión consciente, mismo patrón que viewMode.
Contenido modificado en otro dispositivoEl 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 editorNecesario porque updateListener no dispara en scroll puro. El listener nativo cubre ese hueco.
Debounce de 300 msEquilibrio 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 localStoragePrefijo workspace.notebook.scrollState. es consistente con el patrón de namespacing existente en el proyecto.

Archivos afectados

ArchivoCambio
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]]