Saltar al contenido
SuperScheduler

Módulos ProSe aplica aSuperScheduler Pro

Deshacer, rehacer e historial

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.

Verificado con v0.1.0 · revisado el 7 de octubre de 2026.md

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.

src/UndoablePlanner.tsxtsx
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ónPor defectoEfecto
limit50Entradas 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)
equalsinicio, fin, recurso, textoDos estados de un evento que se consideran iguales no generan entrada
fieldsningunoCampos adicionales para la comparación por defecto
apply'control'Quién aplica deshacer y rehacer: el control o tu función
labelsinglés o españolEtiqueta 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.

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.

src/ControlledUndo.tsxtsx
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.

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.

src/BulkEditing.tsxtsx
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.

src/OptimisticPlanner.tsxtsx
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

Planificación de producción audiovisualUn rodaje se alarga. Mueve la edición que dependía de él, entiende por qué y vuelve atrás. Planificación de recursos en una agencia creativaUna diseñadora tiene doble reserva. Pasa dos tareas a un compañero con un solo arrastre, revisa qué desbloquean y deshazlo.