wandres.dev
UNDO, REDO Y TIME TRAVEL · historia del estado

Granularidad: qué cuenta como un paso de deshacer

Las tres lecciones anteriores resolvieron cómo almacenar el historial; esta resuelve la pregunta que ninguna de ellas responde y que decide si el usuario percibe la función como útil o como enemiga: dónde termina un paso y empieza el siguiente. Un paso de deshacer no es una unidad técnica sino perceptual, y el desajuste entre ambas es la causa de las dos patologías clásicas: el historial demasiado fino, que obliga a pulsar cuarenta veces para borrar una frase escrita hace un momento, y el demasiado grueso, que se lleva por delante diez minutos de trabajo con una sola pulsación. La lección desarrolla las heurísticas de agrupación que usan los editores serios —ventana temporal, cambio de tipo de operación, frontera léxica, salto de cursor, pérdida de foco y confirmación explícita—, las implementa como una función de decisión sobre transacciones abiertas, y cierra delimitando qué nunca debe entrar en la pila: la selección, el desplazamiento, los cambios llegados del servidor y las ediciones de otros usuarios en sesiones colaborativas.

⏱ 19 min

Un usuario escribe una frase, se arrepiente y pulsa la combinación de deshacer. Lo que espera es que desaparezca la frase. Si desaparece la última letra, y luego la penúltima, y descubre que va a necesitar cuarenta pulsaciones para volver donde estaba, la función ha fracasado aunque su implementación sea impecable. Si en cambio desaparece la frase y también el párrafo anterior y también el cambio de estilo que hizo hace cinco minutos, el fracaso es peor, porque además ha perdido trabajo. Entre esos dos abismos hay una franja estrecha y sin fórmula cerrada que es donde vive el buen deshacer, y encontrarla no es un problema de estructuras de datos sino de modelar la percepción. El historial, tal como lo hemos construido hasta ahora, registra cambios de estado; el usuario, en cambio, recuerda acciones, y una acción suya puede corresponder a un cambio, a cuarenta o a ninguno. Esta lección se ocupa de esa traducción. Es la menos formal del nivel y probablemente la que más determina si el producto se siente sólido, porque deshacer es una promesa de reversibilidad y una promesa que se cumple de forma impredecible no tranquiliza a nadie: inquieta.

🎯 Al terminar esta lección sabrás
  • Definir el paso de deshacer como unidad perceptual y diagnosticar las patologías del historial fino y del grueso.
  • Aplicar las seis heurísticas de agrupación: tiempo, tipo de operación, frontera léxica, salto de cursor, foco y confirmación.
  • Implementar la fusión de cambios en una transacción abierta con un criterio de cierre explícito.
  • Excluir del historial lo que no es una edición del usuario: selección, desplazamiento y cambios remotos.

El paso es una unidad perceptual

La granularidad correcta se deriva de una única pregunta: qué recordará el usuario como una sola cosa que hizo. Escribir una palabra es una cosa, aunque hayan sido siete pulsaciones. Arrastrar un elemento hasta su sitio es una cosa, aunque el gestor de arrastre haya emitido doscientas actualizaciones de posición. Cambiar el color de una selección de treinta objetos es una cosa, aunque el modelo haya tocado treinta nodos. En los tres casos el número de cambios técnicos y el número de acciones percibidas difieren en uno o dos órdenes de magnitud, y el historial que registra el primero produce una experiencia que nadie describiría como deshacer.

Ese desajuste tiene además un componente temporal que agrava el problema: cuanto más lejos queda la acción, más gruesa la recuerda el usuario. Lo que hace un segundo eran tres pulsaciones distintas, un minuto después es escribí el título, y media hora después es preparé el documento. Un historial estático no puede seguir esa compresión del recuerdo, y por eso algunos editores fusionan pasos antiguos de forma más agresiva que los recientes, acercando la estructura del historial a la de la memoria.

La patología inversa es menos frecuente pero más grave. Agrupar por sesión, por guardado o por intervalos largos y fijos produce pasos enormes en los que caben cambios que el usuario considera separados, y entonces deshacer deja de ser una herramienta de precisión para convertirse en una ruleta. La asimetría entre ambos errores es importante para calibrar: un paso demasiado fino cuesta pulsaciones y paciencia, un paso demasiado grueso cuesta trabajo. Ante la duda, conviene equivocarse por el lado fino.

flowchart TD
C[llega un cambio] --> T{hay transaccion abierta}
T -->|no| N[abrir transaccion nueva]
T -->|si| M{mismo tipo y misma zona}
M -->|no| CI[cerrar y abrir otra]
M -->|si| V{dentro de la ventana temporal}
V -->|no| CI
V -->|si| F[fusionar en la transaccion abierta]
style F fill:#a6e3a1,color:#11111b
style CI fill:#f9e2af,color:#11111b

Las seis heurísticas de agrupación

Ningún editor serio usa un solo criterio. La agrupación real es una conjunción de condiciones donde basta que una falle para cerrar el paso en curso y abrir uno nuevo. Estas son las seis que cubren prácticamente todos los casos, ordenadas de más a menos universal.

⏱️

Ventana temporal

Dos cambios separados por más de un umbral —entre trescientos milisegundos y un segundo según el dominio— pertenecen a pasos distintos. La pausa es la señal más fiable de que el usuario terminó de pensar una cosa y empezó otra.

🔁

Cambio de tipo de operacion

Escribir y borrar son intenciones distintas aunque ocurran seguidas. Al pasar de insertar a eliminar, o de mover a redimensionar, se cierra el paso: mezclarlos produce un deshacer que hace dos cosas contrarias a la vez.

📝

Frontera lexica

En texto, el espacio, el salto de línea y la puntuación cierran el paso. Así una pulsación de deshacer retira exactamente una palabra, que es la unidad que el usuario tiene en la cabeza cuando escribe.

🎯

Salto de cursor o de seleccion

Si el punto de edición se mueve a otro lugar del documento, lo que venga después es otra acción. Escribir en el título y luego en el pie no puede deshacerse de una vez, por próximos que estén en el tiempo.

🚪

Perdida de foco

Salir de un campo, cerrar un panel o cambiar de pestaña cierra cualquier transacción abierta. Es el momento en que el usuario da por terminado lo que estaba haciendo, y dejar la transacción viva produce fusiones sorprendentes al volver.

Confirmacion explicita

Pulsar guardar, aceptar un diálogo o soltar el ratón tras un arrastre son cierres inequívocos. Cuando existe una confirmación explícita, es siempre el criterio dominante y anula a los demás.

Las seis no tienen el mismo peso ni se aplican en el mismo orden. La confirmación explícita domina siempre, porque cuando el usuario ha señalado un final no hay heurística que deba contradecirle. Por debajo, la pérdida de foco y el salto de cursor actúan como cierres duros: son señales estructurales de que el contexto cambió. Y en el nivel más bajo, la ventana temporal, el tipo de operación y la frontera léxica son cierres blandos que solo se consultan cuando ninguno de los anteriores se ha disparado. Respetar esa jerarquía evita el fallo típico de las implementaciones caseras, que consultan solo el reloj y acaban fusionando dos ediciones sin relación por el mero hecho de haber ocurrido seguidas.

Implementarlas es más simple de lo que la lista sugiere, porque todas se reducen a una función que decide si un cambio entrante se fusiona con la transacción abierta o la cierra. Mantener esa decisión en un solo lugar, en vez de repartida entre los manejadores de eventos, es lo que evita que la granularidad se degrade con el tiempo a medida que el producto crece.

type Tipo = "insertar" | "eliminar" | "mover" | "estilo"
type Abierta = { tipo: Tipo; zona: string; ultimoMs: number }

const VENTANA_MS = 600

function debeFusionar(a: Abierta | null, tipo: Tipo, zona: string, ahora: number): boolean {
  if (!a) return false
  if (a.tipo !== tipo) return false                 // heuristica 2
  if (a.zona !== zona) return false                 // heuristica 4
  return ahora - a.ultimoMs <= VENTANA_MS           // heuristica 1
}

function cierraPorTexto(caracter: string): boolean {
  return /[\s.,;:!?]/.test(caracter)                // heuristica 3
}
💡
Ofrece siempre una etiqueta y hazla visible

Un paso agrupado debería poder nombrarse: escribir palabra, mover figura, aplicar estilo. Si el historial es de comandos o de parches enriquecidos con semántica, esa etiqueta ya existe, y mostrarla junto al botón de deshacer convierte una operación a ciegas en una decisión informada. El beneficio no es estético: cuando el usuario lee lo que va a deshacer antes de pulsar, los errores de granularidad dejan de ser sorpresas y pasan a ser algo que puede anticipar y evitar.

Qué no entra en la pila

Tan importante como agrupar bien es decidir qué queda fuera. La regla es que el historial registra ediciones del usuario sobre el documento, no todo lo que hace cambiar el estado. Cuatro categorías se cuelan con frecuencia y deben excluirse de forma deliberada.

La primera son las vistas efímeras: selección, posición del cursor, desplazamiento, paneles abiertos, pestaña activa. Registrarlas produce pasos vacíos en los que deshacer no cambia nada visible salvo un resalte, y el usuario concluye que la función está rota. Hay un matiz importante: aunque la selección no sea un paso, sí conviene restaurarla como efecto lateral de un deshacer, porque devolver el cursor al lugar donde ocurrió el cambio es exactamente lo que la persona espera.

La segunda son los cambios de origen remoto: datos que llegan del servidor, resultados de una consulta que se refresca, notificaciones que actualizan un contador. No los hizo el usuario y no tiene sentido que pueda deshacerlos. La tercera son los estados derivados, que no deben guardarse en ninguna circunstancia porque se recalculan a partir del estado base, y almacenarlos en el historial abre la puerta a restaurar una derivación incoherente con su origen. La cuarta y más delicada son las ediciones de otros usuarios en una sesión colaborativa: el deshacer debe ser local, es decir, retirar únicamente los cambios propios y dejar intactos los ajenos, lo que exige atribuir autoría a cada entrada del historial y filtrar por ella.

// Filtro de admision: solo entra al historial lo que es edicion propia
function esDeshacible(ev: { origen: "usuario" | "remoto"; tipo: string; autor: string }, yo: string) {
  if (ev.origen !== "usuario") return false
  if (ev.autor !== yo) return false                       // deshacer local
  return !["seleccion", "scroll", "foco", "hover"].includes(ev.tipo)
}
⚠️
El deshacer global en sesiones colaborativas es casi siempre un error

Cuando varias personas editan a la vez, el deshacer que retira el último cambio del documento sin mirar quién lo hizo produce una experiencia inaceptable: alguien pulsa la combinación esperando retirar su propia frase y borra el párrafo que otro acaba de escribir. La expectativa universal es que deshacer sea local, es decir, que recorra el historial hacia atrás saltándose las entradas ajenas hasta encontrar la propia. Implementarlo exige dos cosas: atribuir autoría a cada entrada y aceptar que el paso recuperado quizá ya no se pueda revertir tal cual, porque cambios posteriores de otros lo han desplazado. Ahí es donde el historial lineal se queda corto y hacen falta las técnicas de transformación que estudia el nivel de colaboración.

Calibrar con datos, no con opiniones

Como la granularidad no tiene solución cerrada, la única forma honesta de fijarla es medirla. Y hay dos señales baratas de instrumentar que dicen casi todo. La primera son las ráfagas: contar cuántas pulsaciones de deshacer consecutivas ocurren dentro de una ventana corta. Si la distribución tiene una cola larga —usuarios que pulsan quince o veinte veces seguidas— el historial es demasiado fino y está obligando a la gente a martillear la tecla. La segunda es el arrepentimiento: medir cuántos deshacer van seguidos inmediatamente de un rehacer. Ese patrón significa que el paso se llevó por delante más de lo que la persona esperaba y tuvo que devolverlo, que es la firma exacta de un historial demasiado grueso.

// Dos senales que revelan la granularidad sin preguntar a nadie
type Metrica = { rafagas: number[]; deshacerSeguidoDeRehacer: number }

// rafaga larga y frecuente  -> pasos demasiado finos
// mucho deshacer + rehacer  -> pasos demasiado gruesos
const veredicto = (m: Metrica) =>
  m.rafagas.filter((n) => n >= 10).length > m.rafagas.length * 0.1
    ? "demasiado fino"
    : m.deshacerSeguidoDeRehacer > 0.15
      ? "demasiado grueso"
      : "razonable"

Estas dos métricas tienen la virtud de ser accionables en direcciones opuestas, así que basta con ajustar la ventana temporal y las condiciones de cierre hasta que ambas queden bajas a la vez. En la práctica el punto de equilibrio depende del dominio, y por eso no hay una constante universal: un editor de texto quiere pasos del tamaño de una palabra, un editor gráfico del tamaño de un gesto completo, y un formulario del tamaño de un campo. Copiar la ventana temporal de otro producto sin entender su unidad de trabajo es la forma más rápida de heredar una granularidad que no encaja.

Queda una comprobación cualitativa que ninguna métrica sustituye y que cuesta diez minutos: sentarse con alguien, pedirle que haga una tarea real y observar en qué momento pulsa deshacer y qué esperaba que ocurriera. Casi siempre aparece al menos un caso donde el modelo del programa y el de la persona divergen de un modo que nadie había anticipado, y ese caso concreto vale más que cualquier discusión de equipo sobre el umbral correcto.

Deshacer es un contrato de confianza, no una pila

La tentación al llegar a este punto del nivel es pensar que el trabajo duro fue el de las tres lecciones anteriores y que la granularidad es afinado cosmético. Es al revés. Las estructuras de datos son la parte fácil porque están completamente especificadas: hay una respuesta correcta y se puede demostrar. La granularidad no tiene respuesta correcta demostrable, y sin embargo es lo único que el usuario percibe, porque nadie experimenta una pila: se experimenta si el botón hace lo que uno esperaba. Y lo que se juega ahí no es comodidad sino algo más básico, la disposición a explorar. Un deshacer fiable convierte cada acción en reversible y con ello elimina el coste psicológico de probar; un deshacer impredecible hace lo contrario, porque el usuario aprende deprisa que pulsarlo puede empeorar las cosas y deja de usarlo, y a partir de ese momento trabaja con cautela, guarda copias por si acaso y evita las funciones que no domina. Todo eso es invisible en las métricas y sin embargo decide cómo se siente el producto. La lección transferible es que deshacer no es una funcionalidad del editor sino una propiedad de la relación entre la persona y la herramienta: promete que nada de lo que hagas es definitivo. Se implementa con pilas, pero se sostiene con predecibilidad, y una promesa que se cumple de forma irregular es peor que no haberla hecho.

⚔️ Calibra la granularidad hasta que se sienta bien
  1. Implementa debeFusionar sobre un editor de texto simple y prueba la ventana temporal con cien, seiscientos y tres mil milisegundos. Anota cómo cambia la sensación en cada caso.
  2. Añade la frontera léxica y verifica que una pulsación de deshacer retira exactamente una palabra en lugar de un carácter.
  3. Implementa el cierre por cambio de tipo y construye el caso que lo justifica: escribir, borrar y volver a escribir sin pausas.
  4. Añade el cierre por salto de cursor y comprueba que editar dos campos alternándolos genera cuatro pasos y no uno.
  5. Instrumenta un arrastre que emita doscientos cambios de posición y agrúpalo en un único paso cerrado al soltar el ratón.
  6. Aplica el filtro de admisión: haz que llegue una actualización del servidor durante la edición y demuestra que no aparece en el historial, pero que la selección sí se restaura al deshacer.
  7. Instrumenta las dos métricas de calibración, recoge datos de tres personas usando el editor durante cinco minutos y emite un veredicto razonado sobre tu ventana temporal.
  8. Diseña el deshacer local para dos autores simultáneos y describe con un ejemplo concreto el caso en que el paso propio ya no se puede revertir tal cual.