Undo and redo
The history is off by default. With history on, every action is an entry: a drag, an
updateTask, a link that was drawn, a link that was removed. undo() applies the inverse of the
last entry and redo() puts it back; canUndo and canRedo say whether there is anything to do,
and onHistoryChange tells you when either flips, which is what the buttons follow.
The keys are off by default too. keyboard.undo makes Ctrl or Cmd + Z undo and Ctrl or Cmd +
Shift + Z (or Ctrl + Y) redo, while the focus is on a bar or on the chart; the chart calls the same two
methods as the buttons.
pnpm add @ganttlane/dom # vanillapnpm add @ganttlane/react react react-dom # Reactnpm install @ganttlane/dom # vanillanpm install @ganttlane/react react react-dom # Reactyarn add @ganttlane/dom # vanillayarn add @ganttlane/react react react-dom # Reactbun add @ganttlane/dom # vanillabun add @ganttlane/react react react-dom # Reactimport type { Link, Task } from '@ganttlane/core';
// 2026-03-02 is a Monday. Drag a few bars, draw a link, then undo them one by one.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 100 }, { id: 'api', name: 'API', start: '2026-03-09', end: '2026-03-13', progress: 50 }, { id: 'ui', name: 'UI', start: '2026-03-09', end: '2026-03-17', progress: 30 }, { id: 'docs', name: 'Docs', start: '2026-03-16', end: '2026-03-19' }, { id: 'qa', name: 'QA', start: '2026-03-18', end: '2026-03-20' }, { id: 'review', name: 'Review', start: '2026-03-23', end: '2026-03-24' }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-03-25' },];
export const links: Link[] = [ { source: 'design', target: 'api' }, { source: 'design', target: 'ui' }, { source: 'api', target: 'qa' }, { source: 'ui', target: 'qa' }, { source: 'qa', target: 'review' }, { source: 'review', target: 'launch' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Undo and redo. They are off by default. `true` turns them on and keeps the last 100 actions; // an object sets `limit`, the most actions kept (the oldest go first). Here: the last five, so // that the limit is easy to reach. The example changes it with `setOptions({ history })`. history: { limit: 5 }, // The keys: Ctrl or Cmd + Z undoes and Ctrl or Cmd + Shift + Z (or Ctrl + Y) redoes, while the // focus is on a bar (`barFocus` lets the bars take it) or on the chart. Off by default too. keyboard: { barFocus: true, undo: true }, viewMode: 'week', scroll: { initial: 'first-task' }, // Smaller rows, so that all seven tasks are in view at once. layout: { rowHeight: 36, barHeight: 24 },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt, type TaskId } from '@ganttlane/dom';
import { links, tasks } from './data';import { changeText, historyText, LIMITS, undoAll } from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const controller = new AbortController(); const { signal } = controller; const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const chart = document.createElement('div'); chart.style.height = '340px'; const status = document.createElement('p'); root.append(toolbar, chart, status); container.append(root);
// The status: the last thing a button or a key did, the last change, and the last history event. const lines = { call: '', change: 'Drag bars, draw a link, then undo.', history: '' }; const show = (): void => { status.textContent = [lines.call, lines.change, lines.history].filter(Boolean).join(' '); }; // Whether a button or a key is making a call just now: its events belong to it, and the line // of the last call is no news for the ones that come from a drag. let calling = false; const note = (change: string): void => { if (!calling) lines.call = ''; lines.change = change; show(); };
// ---- the buttons: `undo()` and `redo()` act, `canUndo` and `canRedo` say whether they can ---- const addButton = (label: string, title: string, onClick: () => void): HTMLButtonElement => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.title = title; button.addEventListener('click', onClick, { signal }); toolbar.append(button); return button; }; const call = (make: () => string): void => { lines.change = ''; calling = true; try { lines.call = make(); } finally { calling = false; } show(); }; // The result says what happened: 'undone' or 'redone', or 'empty' when there was nothing. const run = (action: 'undo' | 'redo'): void => { call(() => `${action}(): ${gantt[action]().status}.`); }; const undo = addButton('Undo', 'Undo (Ctrl or Cmd + Z)', () => { run('undo'); }); const redo = addButton('Redo', 'Redo (Shift + Ctrl or Cmd + Z)', () => { run('redo'); }); const undoAllButton = addButton('Undo all', 'Undo until nothing is left', () => { call(() => `Undid ${undoAll(gantt)} actions.`); }); // The history keeps the last `limit` actions: a smaller limit drops the oldest ones at once. const limit = document.createElement('select'); limit.setAttribute('aria-label', 'Actions the history keeps'); for (const value of LIMITS) { const option = document.createElement('option'); option.textContent = String(value); limit.append(option); } limit.value = String(options.history.limit); limit.addEventListener( 'change', () => { gantt.setOptions({ history: { limit: Number(limit.value) } }); }, { signal }, ); const limitLabel = document.createElement('label'); limitLabel.append('Keep the last ', limit, ' actions'); toolbar.append(limitLabel);
// The handlers read `gantt`, which they are passed to: give it a type. const nameOf = (id: TaskId): string => gantt.getTask(id)?.name ?? String(id); const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // `history: { limit: 5 }`: without it `undo()` has nothing to undo onTasksChange: (changeSet) => { note(changeText(changeSet, nameOf)); }, onLinkCreate: (link) => { note(`onLinkCreate: ${nameOf(link.source)} → ${nameOf(link.target)}.`); }, onLinkDelete: (link) => { note(`onLinkDelete: ${nameOf(link.source)} → ${nameOf(link.target)}.`); }, // `canUndo` or `canRedo` changed: the buttons follow it. onHistoryChange: (payload) => { undo.disabled = undoAllButton.disabled = !payload.canUndo; redo.disabled = !payload.canRedo; lines.history = historyText(payload); show(); }, }); undo.disabled = undoAllButton.disabled = !gantt.canUndo; redo.disabled = !gantt.canRedo; show();
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle, type TaskId } from '@ganttlane/react';import { useCallback, useRef, useState } from 'react';
import { links, tasks } from './data';import { changeText, historyText, LIMITS, undoAll } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [limit, setLimit] = useState<number>(options.history.limit); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); // The status: the last thing a button or a key did, the last change, and the last history event. const [call, setCall] = useState(''); const [change, setChange] = useState('Drag bars, draw a link, then undo.'); const [history, setHistory] = useState(''); // `viewMode` is a controlled prop in React: `defaultViewMode` is where the chart starts, and // then the chart owns it. const { viewMode, ...rest } = options; const nameOf = (id: TaskId): string => chart.current?.getTask(id)?.name ?? String(id); // Whether a button or a key is making a call just now: its events belong to it, and the line // of the last call is no news for the ones that come from a drag. const calling = useRef(false); const note = (text: string): void => { if (!calling.current) setCall(''); setChange(text); };
const perform = useCallback((make: (handle: GanttHandle) => string): void => { if (!chart.current) return; setChange(''); calling.current = true; try { setCall(make(chart.current)); } finally { calling.current = false; } }, []); // The result says what happened: 'undone' or 'redone', or 'empty' when there was nothing. const run = useCallback( (action: 'undo' | 'redo'): void => { perform((handle) => `${action}(): ${handle[action]().status}.`); }, [perform], );
return ( <div> <div role="toolbar"> <button type="button" title="Undo (Ctrl or Cmd + Z)" disabled={!canUndo} onClick={() => run('undo')} > Undo </button> <button type="button" title="Redo (Shift + Ctrl or Cmd + Z)" disabled={!canRedo} onClick={() => run('redo')} > Redo </button> <button type="button" title="Undo until nothing is left" disabled={!canUndo} onClick={() => { perform((handle) => `Undid ${undoAll(handle)} actions.`); }} > Undo all </button> <label> Keep the last{' '} <select aria-label="Actions the history keeps" value={limit} onChange={(event) => setLimit(Number(event.target.value))} > {LIMITS.map((value) => ( <option key={value}>{value}</option> ))} </select>{' '} actions </label> </div> <Gantt ref={chart} style={{ height: 340 }} // `tasks` and `links` are controlled props in React: these are where the chart starts. defaultTasks={tasks} defaultLinks={links} {...rest} defaultViewMode={viewMode} // The history keeps the last `limit` actions: a smaller limit drops the oldest at once. history={{ limit }} onTasksChange={(changeSet) => { note(changeText(changeSet, nameOf)); }} onLinkCreate={(link) => { note(`onLinkCreate: ${nameOf(link.source)} → ${nameOf(link.target)}.`); }} onLinkDelete={(link) => { note(`onLinkDelete: ${nameOf(link.source)} → ${nameOf(link.target)}.`); }} // `canUndo` or `canRedo` changed: the buttons follow it. onHistoryChange={(payload) => { setCanUndo(payload.canUndo); setCanRedo(payload.canRedo); setHistory(historyText(payload)); }} /> <p>{[call, change, history].filter(Boolean).join(' ')}</p> </div> );}import type { ChangeSet, Gantt, HistoryChangePayload, TaskId } from '@ganttlane/dom';
/** The limits of the select: the most actions the history keeps (100 is the default). */export const LIMITS = [3, 5, 100] as const;
/** * Undoes until there is nothing left to undo. * * @param chart - The chart. * @returns How many actions it undid. */export function undoAll(chart: Pick<Gantt, 'undo'>): number { let count = 0; while (chart.undo().status === 'undone') count += 1; return count;}
/** A line for the status: what `onTasksChange` reported (its `reason` is `'undo'` or `'redo'` too). */export function changeText({ reason, changes }: ChangeSet, nameOf: (id: TaskId) => string): string { return `onTasksChange (${reason}): ${changes.map((change) => nameOf(change.id)).join(', ')}.`;}
/** A line for the status: what `onHistoryChange` reported. */export function historyText({ canUndo, canRedo }: HistoryChangePayload): string { return `onHistoryChange: canUndo ${String(canUndo)}, canRedo ${String(canRedo)}.`;}Files
data.ts: seven tasks and six links.options.ts:historywith its limit,keyboard(bars take the focus, the undo keys), the view mode, the first scroll and the layout.helpers.ts:undoAll, and the lines of the status.vanilla.tsandreact.tsx: the buttons, the select for the limit and the events.
Options
| Option | Value | What it does |
|---|---|---|
history |
{ limit: 5 } |
Undo and redo on, and the last five actions kept; true is on with the default 100 |
keyboard |
{ barFocus, undo: true } |
Bars take the focus; Ctrl or Cmd + Z undoes, Ctrl or Cmd + Shift + Z and Ctrl + Y redo |
onHistoryChange |
a function (in the example) | ({ canUndo, canRedo }), only when one of them changes: the buttons follow it |
onTasksChange |
a function (in the example) | A change set, also for an undo or a redo (reason: 'undo' or 'redo') |
onLinkCreate, onLinkDelete |
functions (in the example) | A link drawn or removed, also by an undo or a redo |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan |
layout.rowHeight, .barHeight |
36, 24 |
Smaller rows, so that every task is in view |
Methods
| Method | What it does |
|---|---|
undo() and redo() |
Undo the last entry, redo the last undone one; they return { status } at once |
canUndo and canRedo |
Whether there is an entry to undo or to redo |
setOptions({ history }) |
Changes the limit (or turns the history off) without recreating the chart |
What to try
- Drag API and UI, change a progress with the little handle, draw a link from Docs to Review. Each is an entry, and the buttons turn on as soon as there is something to undo.
- Press Undo: the bar goes back,
onTasksChangereports it withreason: 'undo', and the call returnsundone. Redo puts it back. Make a new change after an undo:canRedogoes false, because the redo entries no longer apply. - Click a bar, or Tab to one, then press Ctrl+Z (Cmd+Z on a Mac), and Shift+Ctrl+Z to redo. The keys work while the focus is on a bar or on the chart; in the select they are the browser’s.
- Undo the link you drew: it is removed, and
onLinkDeletereports it. - Choose Keep the last 3 actions, make five changes and press Undo all: it undoes three. Lowering the limit drops the oldest entries at once.
Notes
- What is an entry. Committed changes (drags and
updateTask, with everything they moved), link creations and link removals. Selection, scrolling and the view mode are not. - No hooks, but a check. An undo or a redo does not ask
onBeforeTaskChange, so a hook cannot stand in the way of putting things back (Before-hooks). It is checked against the data all the same: with controlled data, an entry that no longer fits the data givesstaleand clears the history. With the chart’s own data, replacing the tasks withsetTasksclears the history at once (canUndogoes false). - The results.
undone,redone,empty(nothing to do),busy(a hook is waiting, or controlled data holds a change its owner has not applied) andstale. - The limit.
history: truekeeps the last 100 actions,{ limit: n }the lastn(undo and redo entries together, the oldest dropped first),{ enabled: false }or leaving it out turns the history off. - Edit sessions. A session that is rolled back (
edit.rollback()) puts the history back as it was when the session began. - React.
historyis an ordinary prop. The buttons followonHistoryChangethrough state;useGanttStatecould read the flags from the snapshot instead.