# Deshacer, rehacer e historial

> Registra movimientos y cambios de tamaño con super-scheduler/history, aplica deshacer sobre estado controlado, agrupa cambios y revierte lo que rechaza tu servidor.

Source: https://superscheduler.org/es/docs/undo-redo/
Reviewed: 2026-10-07

Crea un historial con createHistory() y pásalo en la prop history: se registran los movimientos y los cambios de tamaño, y Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z y Ctrl+Y funcionan mientras el foco está en el scheduler. Con eventos controlados, adopta los cambios 'history' que llegan a onEventsChange, o dale a createHistory una función apply que actualice tu estado. Usa push para tus propios comandos, batch para agrupar cambios, revert para deshacer un cambio que tu servidor rechazó, y subscribe u onHistoryChange para controlar los botones Deshacer y Rehacer.

La manipulación directa invita a equivocarse: un evento que cae una fila más abajo, un cambio de tamaño que se pasa un día. `super-scheduler/history` mantiene una pila de deshacer con los cambios de eventos y con tus propios comandos, e incluye atajos de teclado, etiquetas para los botones, agrupación y una forma de revertir los cambios que rechaza tu servidor. Vive en memoria durante la sesión; guardar cualquier cosa es trabajo de tu aplicación.

El historial requiere SuperScheduler Pro.

## Conectar un historial
`createHistory()` devuelve un objeto de historial. Pásalo al scheduler como `history` (o dentro de `extensions`). Créalo una sola vez: las props se comparan por identidad, y un historial creado durante el render sería uno nuevo y vacío en cada render.

```tsx
// src/UndoablePlanner.tsx
import { useEffect, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerHistoryChangeArgs, SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'
import type { SchedulerHistory } from 'super-scheduler/history'
import 'super-scheduler/styles.css'

type HistoryState = Pick<
  SchedulerHistoryChangeArgs,
  'canUndo' | 'canRedo' | 'undoLabel' | 'redoLabel'
>

/** Mirrors the history in React: one render per change, never per gesture frame. */
export function useHistoryState(history: SchedulerHistory): HistoryState {
  const [state, setState] = useState<HistoryState>(() => ({
    canUndo: history.canUndo,
    canRedo: history.canRedo,
    undoLabel: history.undoLabel,
    redoLabel: history.redoLabel,
  }))
  useEffect(() => history.subscribe(setState), [history])
  return state
}

export function UndoablePlanner(props: {
  resources: SuperScheduler.ResourceData[]
  initialEvents: SuperScheduler.EventData[]
}) {
  // Created once: `history` is compared by identity, like every prop.
  const [history] = useState(() =>
    createHistory({
      limit: 100,
      // Default: moves and resizes. Mod+Z, Mod+Shift+Z and Ctrl+Y work while focus is in the grid.
      record: ['move', 'resize'],
      keys: 'root',
      labels: { move: 'move', resize: 'resize' },
    }),
  )
  const [initial] = useState(() => props.initialEvents.slice())
  const state = useHistoryState(history)

  return (
    <>
      <div role="toolbar" aria-label="History">
        <button type="button" disabled={!state.canUndo} onClick={() => history.undo()}>
          {state.undoLabel === null ? 'Undo' : `Undo ${state.undoLabel}`}
        </button>
        <button type="button" disabled={!state.canRedo} onClick={() => history.redo()}>
          {state.redoLabel === null ? 'Redo' : `Redo ${state.redoLabel}`}
        </button>
      </div>
      <SuperSchedulerComponent
        history={history}
        // Uncontrolled: the control owns the events after mount.
        defaultEvents={initial}
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}
```
Arrastra un evento y el primer botón dirá «Undo move». Haz clic en él, o pulsa Cmd+Z (Ctrl+Z en Windows y Linux) después de hacer clic en un evento, y el evento vuelve a su sitio; «Redo move» lo lleva de nuevo.

Un mismo historial puede servir a varios schedulers, por ejemplo a los paneles de una vista dividida. En ese caso, deshacer sigue el orden de los cambios en todos ellos.

## Opciones
| Opción | Por defecto | Efecto |
|---|---|---|
| `limit` | `50` | Entradas que se conservan; a partir de ahí se descarta la más antigua. `0` no registra nada. |
| `record` | `['move', 'resize']` | Cambios que se registran automáticamente: `'move'`, `'resize'`, `'create'`, `'remove'`, `'update'` |
| `keys` | `'root'` | Dónde se escuchan los atajos: dentro del scheduler, en todo el documento (`'document'`) o en ningún sitio (`false`) |
| `equals` | inicio, fin, recurso, texto | Dos estados de un evento que se consideran iguales no generan entrada |
| `fields` | ninguno | Campos adicionales para la comparación por defecto |
| `apply` | `'control'` | Quién aplica deshacer y rehacer: el control o tu función |
| `labels` | inglés o español | Etiqueta por tipo: `move`, `resize`, `create`, `remove`, `update`, `command` |

Qué se registra:

- **Gestos.** `'move'` y `'resize'` cubren los movimientos y cambios de tamaño con puntero y con teclado. La entrada se crea después de que el cambio se confirma: un movimiento que rechazan tus reglas, o que se cancela durante una confirmación asíncrona, no deja entrada.
- **Cambios por API.** `'create'`, `'remove'` y `'update'` registran las llamadas a `control.events.add()`, `remove()` y `update()`.
- **Nunca.** Las cargas de datos: un nuevo array `events` desde React y los eventos del cargador por rangos. Deshacer y rehacer tampoco se vuelven a registrar.

Los atajos son Cmd+Z y Cmd+Shift+Z en macOS, y Ctrl+Z, Ctrl+Shift+Z y Ctrl+Y en el resto de sistemas. Se ignoran en campos de texto, áreas de texto y elementos editables, cuando se mantiene pulsada Alt y cuando otro handler ya ha gestionado la tecla. Con `keys: 'root'`, el foco tiene que estar dentro del scheduler: hacer clic en un evento o arrastrarlo lo lleva allí, y también el tabulador cuando el teclado está habilitado. `'document'` funciona en cualquier parte de la página, así que úsalo solo si ninguna otra parte de la página tiene su propio deshacer.

> **Behavior:**
> El historial compara el objeto de evento almacenado antes y después de un cambio. Los setters del envoltorio (`e.start(value)`, `e.end(value)`, `e.text(value)`) modifican el objeto almacenado en el sitio, así que un `control.events.update(e)` posterior no deja nada que restaurar y no crea ninguna entrada. Pasa un objeto nuevo en su lugar: `control.events.update({ ...e.data, start, end })`.

## Mostrar el estado del deshacer
El objeto de historial expone `canUndo`, `canRedo`, `undoLabel` y `redoLabel`, además de `undo()` y `redo()`, que devuelven `false` cuando no hay nada que hacer. Hay tres formas de seguir los cambios:

- `history.subscribe(listener)` devuelve una función para cancelar la suscripción, lo que la hace encajar de forma natural en un `useEffect` (el `useHistoryState` del fragmento);
- la prop `onHistoryChange` recibe el mismo estado, con `this` apuntando al control;
- `useScheduler({ track: ['history'] })` de `super-scheduler/hooks` lo expone como estado de React.

Cada notificación incluye un `cause`: `'record'`, `'undo'`, `'redo'`, `'clear'` o `'revert'`. Las etiquetas son tu `label` o la predeterminada para el tipo, en inglés, o en español cuando el `locale` del scheduler empieza por `es`. Para otros idiomas, pasa `labels`.

## Eventos controlados
Cuando el estado de React es el dueño de los eventos (`events` más `onEventsChange`), hay dos formas de aplicar el deshacer.

Con el valor por defecto `apply: 'control'`, deshacer cambia los eventos del control a través de `control.events.*`, y el resultado llega a `onEventsChange` con `reason: 'history'`. Si ya adoptas ahí todos los cambios, el deshacer funciona sin más código.

Con una función `apply`, el historial te entrega las operaciones y tú actualizas tu estado; la nueva prop `events` llega después al control. Esto encaja con stores, reducers y aplicaciones que guardan todos los cambios por un único camino de código.

```tsx
// src/ControlledUndo.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerEventsChangeArgs } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'
import type { HistoryOp } from 'super-scheduler/history'

type EventData = SuperScheduler.EventData

/** Applies history operations to React state. They arrive in order (reversed for undo). */
function applyOps(
  events: EventData[],
  ops: readonly HistoryOp[],
  direction: 'undo' | 'redo',
): EventData[] {
  let next = events
  for (const op of ops) {
    const target = direction === 'undo' ? op.before : op.after
    const id = (target ?? op.before ?? op.after)?.id
    if (id === undefined) continue
    const index = next.findIndex((event) => event.id === id)
    if (target === null) next = next.filter((event) => event.id !== id)
    else if (index >= 0) next = next.map((event, i) => (i === index ? target : event))
    else next = [...next, target]
  }
  return next
}

export function ControlledUndo(props: {
  resources: SuperScheduler.ResourceData[]
  initial: EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  // The control adopts the array it receives and mutates it: give it a copy.
  const owned = useMemo(() => events.slice(), [events])

  const [history] = useState(() =>
    createHistory({
      record: ['move', 'resize'],
      // Undo and redo update React state; the new `events` prop then reaches the control.
      apply: (ops, direction) => setEvents((current) => applyOps(current, ops, direction)),
    }),
  )

  const onEventsChange = useCallback(
    (args: SchedulerEventsChangeArgs) => setEvents([...args.events]),
    [],
  )

  const onTimeRangeSelected = useCallback(
    (args: SuperScheduler.SchedulerTimeRangeSelectedArgs) => {
      if (args.origin !== 'drag') return
      args.control.clearSelection()
      const booking: EventData = {
        id: SuperScheduler.guid(),
        resource: args.resource,
        start: args.start,
        end: args.end,
        text: 'New booking',
      }
      setEvents((current) => [...current, booking])
      // A change made through state is a data load for the control, not a gesture:
      // record it explicitly so it can be undone.
      history.record({
        kind: 'create',
        label: 'new booking',
        ops: [{ before: null, after: booking }],
      })
    },
    [history],
  )

  return (
    <SuperSchedulerComponent
      history={history}
      events={owned}
      onEventsChange={onEventsChange}
      onTimeRangeSelected={onTimeRangeSelected}
      resources={props.resources}
      startDate="2026-10-01"
      days={31}
      scale="Day"
    />
  )
}
```
Cada operación es `{ before, after }`. `before: null` significa que el evento se creó, y `after: null`, que se eliminó. Las operaciones llegan en el orden en que hay que aplicarlas, ya invertidas en el caso de deshacer.

> **Tip:**
> Un cambio que tu aplicación hace modificando el estado es, para el control, una carga de datos y no un gesto, así que nada lo registra. Regístralo tú con `history.record({ kind, label, ops })`, como hace el fragmento al crear una reserva. Cuando un mismo historial sirve a varios schedulers, pasa también `control`.

## Agrupar cambios y añadir comandos
`history.batch(label, run)` convierte todo lo que se registra mientras se ejecuta `run` en una sola entrada. Así, las acciones masivas se deshacen en un solo paso. Los lotes pueden anidarse; prevalece la etiqueta exterior.

`history.push({ label, undo, redo })` añade un comando propio, para cambios ajenos a los eventos del scheduler: un día congelado, un ajuste de un recurso, una dependencia entre eventos. Deshacer y rehacer llaman a tus funciones.

```tsx
// src/BulkEditing.tsx
import { useState } from 'react'
import { SuperSchedulerComponent, useSchedulerControl } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'

export function BulkEditing(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const { controlRef, control } = useSchedulerControl()
  const [initial] = useState(() => props.initial.slice())
  const [frozenDays, setFrozenDays] = useState<ReadonlySet<string>>(new Set())
  // 'update' also records changes made through control.events.update().
  const [history] = useState(() => createHistory({ record: ['move', 'resize', 'update'] }))

  // One undo step for the whole operation.
  const shiftSelected = (days: number) => {
    if (control === null) return
    const selected = control.multiselect.get()
    history.batch(`shift ${selected.length} jobs`, () => {
      for (const e of selected) {
        // A new object: the stored one stays intact as the state undo restores.
        // (The wrapper setters e.start(...) edit the stored object in place.)
        control.events.update({
          ...e.data,
          start: e.start().addDays(days),
          end: e.end().addDays(days),
        })
      }
    })
  }

  // A change outside the scheduler's events, undone through the same history.
  const freezeDay = (day: string) => {
    const add = () => setFrozenDays((current) => new Set(current).add(day))
    const remove = () =>
      setFrozenDays((current) => new Set([...current].filter((item) => item !== day)))
    add()
    history.push({ label: `freeze ${day}`, undo: remove, redo: add })
  }

  return (
    <>
      <button type="button" onClick={() => shiftSelected(1)}>
        Move selection one day later
      </button>
      <button type="button" onClick={() => freezeDay('2026-10-12')}>
        Freeze 12 October
      </button>
      <p>Frozen days: {[...frozenDays].join(', ') || 'none'}</p>
      <SuperSchedulerComponent
        controlRef={controlRef}
        history={history}
        defaultEvents={initial}
        eventClickHandling="Select"
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}
```
Selecciona dos eventos (clic y luego Cmd+clic), pulsa «Move selection one day later» y después «Freeze 12 October». El primer deshacer descongela el día; el segundo devuelve los dos eventos a la vez.

## Flujos confirmados por el servidor
La librería nunca llama a tu backend. Dos patrones cubren la mayoría de las aplicaciones.

**Optimista.** Deja que el movimiento ocurra y se registre, guárdalo en `onEventMoved` y revierte si el servidor lo rechaza. `history.revert(eventId)` devuelve el evento al estado anterior a su última entrada y descarta esa entrada, para que un deshacer posterior no resucite el cambio rechazado.

```tsx
// src/OptimisticPlanner.tsx
import { useCallback, useMemo, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SchedulerProps, SuperScheduler } from 'super-scheduler'
import { createHistory } from 'super-scheduler/history'

export function OptimisticPlanner(props: {
  resources: SuperScheduler.ResourceData[]
  initial: SuperScheduler.EventData[]
}) {
  const [events, setEvents] = useState(props.initial)
  const owned = useMemo(() => events.slice(), [events])
  const [history] = useState(() => createHistory())

  const config = useMemo<SchedulerProps>(
    () => ({
      history,
      onEventsChange: ({ events: next }) => setEvents([...next]),
      // The move is already applied and recorded: save it, and roll back if the server refuses.
      onEventMoved: (args) => {
        const id = args.e.id()
        saveBooking({
          id: String(id),
          resource: String(args.newResource),
          start: args.newStart.value,
          end: args.newEnd.value,
        }).catch(() => {
          // Restores the event's previous state and drops that history entry.
          history.revert(id)
          args.control.message('The move could not be saved and was undone.')
        })
      },
    }),
    [history],
  )

  const undo = useCallback(() => history.undo(), [history])

  return (
    <>
      <button type="button" onClick={undo}>
        Undo
      </button>
      <SuperSchedulerComponent
        {...config}
        events={owned}
        resources={props.resources}
        startDate="2026-10-01"
        days={31}
        scale="Day"
      />
    </>
  )
}
```
Cuando el guardado falla, el evento vuelve a su sitio y la barra de mensajes explica el motivo.

**Confirmar primero.** Pregunta al servidor (o al usuario) antes de aplicar el cambio: asigna `args.async = true` en `onEventMove` y luego llama a `args.loaded()` para aceptarlo, o a `args.preventDefault()` y `args.loaded()` para rechazarlo. El historial registra el movimiento solo cuando se acepta. En flujos donde el cambio confirmado difiere del gesto (un diálogo que edita el resultado), pon `record: []` y añade un comando con `push` cuando el servidor confirme.

Deshacer y rehacer también son cambios: guárdalos de la misma manera, ya sea en tu función `apply` o cuando `onEventsChange` informe de `reason: 'history'`. Detectar que otra persona ha cambiado el evento mientras tanto (versiones, conflictos) es trabajo de tu backend.

## Qué no hace el historial
> **Limitation:**
> El historial está en memoria y es de cada página: no sobrevive a una recarga y no se comparte entre pestañas ni entre usuarios. Solo registra eventos; los recursos, los vínculos, la selección, el zoom y el scroll no se registran salvo que añadas tus propios comandos (consulta las [vistas guardadas](https://superscheduler.org/es/docs/panes-saved-views/) para el zoom y el scroll). Llama a `history.clear()` cuando cargues otro conjunto de datos, para que deshacer no pueda aplicar estados antiguos a datos nuevos.

## Relacionado
→ https://superscheduler.org/es/examples/video-production/
→ https://superscheduler.org/es/examples/agency-campaigns/
- [Eventos controlados y callbacks](https://superscheduler.org/es/docs/controlled-state/) para `onEventsChange` y sus motivos.
- [Arrastrar, redimensionar y reglas de negocio](https://superscheduler.org/es/docs/drag-resize-rules/) para la confirmación asíncrona.
