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.
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.
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 acontrol.events.add(),remove()yupdate(). - Nunca. Las cargas de datos: un nuevo array
eventsdesde 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 unuseEffect(eluseHistoryStatedel fragmento);- la prop
onHistoryChangerecibe el mismo estado, conthisapuntando al control; useScheduler({ track: ['history'] })desuper-scheduler/hookslo 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.
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.
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.
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
Relacionado
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.
- Eventos controlados y callbacks para
onEventsChangey sus motivos. - Arrastrar, redimensionar y reglas de negocio para la confirmación asíncrona.
Ejemplos relacionados
- FramehousePlanificación de producción audiovisualUn rodaje se alarga. Mueve la edición que dependía de él, entiende por qué y vuelve atrás.
- Studio NorthPlanificació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.