# Annuler, rétablir et historique

> Enregistrez déplacements et redimensionnements avec super-scheduler/history, regroupez des modifications, ajoutez vos commandes et annulez ce que refuse le serveur.

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

Créez un historique avec createHistory() et passez-le dans la prop history : les déplacements et redimensionnements sont enregistrés, et Ctrl/Cmd+Z, Ctrl/Cmd+Maj+Z et Ctrl+Y fonctionnent tant que le focus est dans le planificateur. Avec des événements contrôlés, adoptez les modifications 'history' reçues dans onEventsChange, ou donnez à createHistory une fonction apply qui met à jour votre état. Utilisez push pour vos propres commandes, batch pour regrouper des modifications, revert pour annuler une modification refusée par votre serveur, et subscribe ou onHistoryChange pour piloter des boutons Annuler et Rétablir.

La manipulation directe invite à l’erreur : un événement déposé une ligne trop bas, un redimensionnement qui va un jour trop loin. `super-scheduler/history` tient une pile d’annulation des modifications d’événements et de vos propres commandes, avec raccourcis clavier, libellés pour les boutons, regroupement et retour arrière pour les modifications que votre serveur rejette. Cet historique vit en mémoire le temps de la session ; enregistrer quoi que ce soit est le travail de votre application.

L’historique nécessite SuperScheduler Pro.

## Attacher un historique
`createHistory()` renvoie un objet historique. Passez-le au planificateur dans `history` (ou dans `extensions`). Créez-le une seule fois : les props sont comparées par identité, et un historique créé pendant le rendu serait un nouvel historique vide à chaque rendu.

```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"
      />
    </>
  )
}
```
Faites glisser un événement : le premier bouton affiche « Undo move ». Cliquez dessus, ou appuyez sur Cmd+Z (Ctrl+Z sous Windows et Linux) après avoir cliqué sur un événement, et l’événement revient à sa place ; « Redo move » le déplace à nouveau.

Un même historique peut servir plusieurs planificateurs, par exemple les volets d’une vue scindée. L’annulation suit alors l’ordre des modifications, tous planificateurs confondus.

## Options
| Option | Défaut | Effet |
|---|---|---|
| `limit` | `50` | Nombre d’entrées conservées ; au-delà, la plus ancienne est supprimée. `0` n’enregistre rien. |
| `record` | `['move', 'resize']` | Modifications enregistrées automatiquement : `'move'`, `'resize'`, `'create'`, `'remove'`, `'update'` |
| `keys` | `'root'` | Où les raccourcis sont écoutés : dans le planificateur, dans tout le document (`'document'`), ou nulle part avec `false` |
| `equals` | début, fin, ressource, texte | Deux états d’un événement jugés égaux ne créent pas d’entrée |
| `fields` | aucun | Champs supplémentaires pour la comparaison par défaut |
| `apply` | `'control'` | Qui applique l’annulation et le rétablissement : le contrôle, ou votre fonction |
| `labels` | anglais ou espagnol | Libellé par type : `move`, `resize`, `create`, `remove`, `update`, `command` |

Ce qui est enregistré :

- **Les gestes.** `'move'` et `'resize'` couvrent les déplacements et redimensionnements au pointeur comme au clavier. L’entrée est créée une fois la modification confirmée : un déplacement refusé par vos règles, ou annulé pendant une confirmation asynchrone, ne laisse aucune entrée.
- **Les modifications par l’API.** `'create'`, `'remove'` et `'update'` enregistrent les appels à `control.events.add()`, `remove()` et `update()`.
- **Jamais.** Les chargements de données : un nouveau tableau `events` venu de React, et les événements du chargeur de plages. L’annulation et le rétablissement eux-mêmes ne sont pas réenregistrés.

Les raccourcis sont Cmd+Z et Cmd+Maj+Z sous macOS, et Ctrl+Z, Ctrl+Maj+Z et Ctrl+Y ailleurs. Ils sont ignorés dans les champs de saisie, les zones de texte et les éléments éditables, quand la touche Alt est maintenue, et quand un autre gestionnaire a déjà traité la touche. Avec `keys: 'root'`, le focus doit se trouver dans le planificateur : cliquer sur un événement ou le faire glisser l’y place, tout comme Tab quand le clavier est activé. `'document'` fonctionne partout dans la page ; ne l’utilisez donc que si aucune autre partie de la page n’a sa propre annulation.

> **Behavior:**
> L’historique compare l’objet événement stocké avant et après une modification. Les setters du wrapper (`e.start(value)`, `e.end(value)`, `e.text(value)`) modifient l’objet stocké sur place : un `control.events.update(e)` appelé ensuite n’a plus rien à restaurer et ne crée aucune entrée. Passez plutôt un nouvel objet : `control.events.update({ ...e.data, start, end })`.

## Afficher l’état d’annulation
L’objet historique expose `canUndo`, `canRedo`, `undoLabel` et `redoLabel`, ainsi que `undo()` et `redo()`, qui renvoient `false` quand il n’y a rien à faire. Trois façons de suivre les changements :

- `history.subscribe(listener)` renvoie une fonction de désabonnement, ce qui en fait un `useEffect` tout naturel (le `useHistoryState` de l’extrait) ;
- la prop `onHistoryChange` reçoit le même état, avec `this` lié au contrôle ;
- `useScheduler({ track: ['history'] })` de `super-scheduler/hooks` l’expose sous forme d’état React.

Chaque notification comporte une `cause` : `'record'`, `'undo'`, `'redo'`, `'clear'` ou `'revert'`. Les libellés sont votre `label`, ou le libellé par défaut du type, en anglais, ou en espagnol quand la `locale` du planificateur commence par `es`. Passez `labels` pour les autres langues.

## Événements contrôlés
Quand l’état React possède les événements (`events` plus `onEventsChange`), il existe deux façons d’appliquer l’annulation.

Avec la valeur par défaut `apply: 'control'`, l’annulation modifie les événements du contrôle via `control.events.*`, et le résultat parvient à `onEventsChange` avec `reason: 'history'`. Si vous y adoptez déjà chaque modification, l’annulation fonctionne sans code supplémentaire.

Avec une fonction `apply`, l’historique vous transmet les opérations et vous mettez à jour votre état ; la nouvelle prop `events` parvient ensuite au contrôle. Cette approche convient aux stores, aux reducers et aux applications qui enregistrent chaque modification par un seul chemin de code.

```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"
    />
  )
}
```
Chaque opération est de la forme `{ before, after }`. `before: null` signifie que l’événement a été créé, `after: null` qu’il a été supprimé. Les opérations arrivent dans l’ordre où il faut les appliquer, déjà inversées pour l’annulation.

> **Tip:**
> Une modification que votre application effectue en mettant à jour son état est, pour le contrôle, un chargement de données et non un geste : rien ne l’enregistre. Enregistrez-la vous-même avec `history.record({ kind, label, ops })`, comme le fait l’extrait lorsqu’il crée une réservation. Quand un même historique sert plusieurs planificateurs, passez aussi `control`.

## Regrouper des modifications et ajouter des commandes
`history.batch(label, run)` réunit en une seule entrée tout ce qui est enregistré pendant l’exécution de `run`. Les actions groupées s’annulent alors en une étape. Les lots peuvent s’imbriquer ; c’est le libellé du lot extérieur qui l’emporte.

`history.push({ label, undo, redo })` ajoute une commande à vous, pour des modifications qui sortent des événements du planificateur : un jour gelé, un réglage de ressource, une dépendance entre événements. L’annulation et le rétablissement appellent vos fonctions.

```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"
      />
    </>
  )
}
```
Sélectionnez deux événements (un clic, puis Cmd+clic), appuyez sur « Move selection one day later », puis sur « Freeze 12 October ». La première annulation dégèle le jour ; la seconde remet les deux événements en place d’un seul coup.

## Workflows confirmés par le serveur
La bibliothèque n’appelle jamais votre backend. Deux modèles couvrent la plupart des applications.

**Optimiste.** Laissez le déplacement se produire et s’enregistrer, sauvegardez-le dans `onEventMoved` et revenez en arrière si le serveur refuse. `history.revert(eventId)` rétablit l’événement dans l’état qui précédait sa dernière entrée et supprime cette entrée, si bien qu’une annulation ultérieure ne ressuscite pas la modification refusée.

```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"
      />
    </>
  )
}
```
Quand l’enregistrement échoue, l’événement revient à sa place et la barre de message en explique la raison.

**Confirmer d’abord.** Interrogez le serveur (ou l’utilisateur) avant que la modification ne s’applique : définissez `args.async = true` dans `onEventMove`, puis appelez `args.loaded()` pour accepter, ou `args.preventDefault()` puis `args.loaded()` pour refuser. L’historique n’enregistre le déplacement qu’une fois celui-ci accepté. Pour les flux où la modification confirmée diffère du geste (une boîte de dialogue qui modifie le résultat), définissez `record: []` et ajoutez une commande avec `push` après la confirmation du serveur.

L’annulation et le rétablissement sont eux aussi des modifications : enregistrez-les de la même façon, soit dans votre fonction `apply`, soit quand `onEventsChange` signale `reason: 'history'`. Détecter qu’une autre personne a modifié l’événement entre-temps (versions, conflits) relève de votre backend.

## Ce que l’historique ne fait pas
> **Limitation:**
> L’historique vit en mémoire, page par page : il ne survit pas à un rechargement et n’est partagé ni entre onglets ni entre utilisateurs. Il n’enregistre que les événements ; ressources, liens, sélection, zoom et défilement ne sont pas enregistrés, sauf si vous ajoutez vos propres commandes (voir les [vues enregistrées](https://superscheduler.org/fr/docs/panes-saved-views/) pour le zoom et le défilement). Appelez `history.clear()` quand vous chargez un autre jeu de données, afin que l’annulation ne puisse pas appliquer d’anciens états à de nouvelles données.

## Voir aussi
→ https://superscheduler.org/fr/examples/video-production/
→ https://superscheduler.org/fr/examples/agency-campaigns/
- [Événements contrôlés et callbacks](https://superscheduler.org/fr/docs/controlled-state/) pour `onEventsChange` et ses motifs.
- [Glisser, redimensionner et règles métier](https://superscheduler.org/fr/docs/drag-resize-rules/) pour la confirmation asynchrone.
