How drawDB Implements Undo/Redo Functionality for Diagram Edits
DrawDB uses a stack-based history system where every diagram edit is recorded as an immutable action object containing both forward and reverse payloads, enabling seamless traversal through editing states.
The open-source diagram editor drawDB manages editing history through a React context-driven architecture centered on two arrays: an undoStack and a redoStack. This design allows users to reverse and replay any change—from adding tables to moving fields—with predictable behavior. Let's examine how the implementation works in the actual source code.
Core Architecture: UndoRedoContext and the Stack Model
UndoRedoContext: Global History Storage
The foundation lives in src/context/UndoRedoContext.jsx. This React context provides:
undoStack— array of completed actionsredoStack— array of reversed actions waiting to be restoredsetUndoStackandsetRedoStack— state setters for mutation
// src/context/UndoRedoContext.jsx
const UndoRedoContext = createContext();
export function UndoRedoProvider({ children }) {
const [undoStack, setUndoStack] = useState([]);
const [redoStack, setRedoStack] = useState([]);
return (
<UndoRedoContext.Provider
value={{ undoStack, redoStack, setUndoStack, setRedoStack }}
>
{children}
</UndoRedoContext.Provider>
);
}
Components access this via useUndoRedo() from src/hooks/useUndoRedo.js, a thin wrapper that avoids direct context imports:
// src/hooks/useUndoRedo.js
import { useContext } from 'react';
import { UndoRedoContext } from '../context/UndoRedoContext';
export const useUndoRedo = () => useContext(UndoRedoContext);
Action and ObjectType Enums
Every recorded change follows a strict schema defined in src/data/constants.js. The Action enum specifies operation types, while ObjectType identifies diagram elements:
// src/data/constants.js (excerpt)
export const Action = {
ADD: 'ADD',
MOVE: 'MOVE',
DELETE: 'DELETE',
EDIT: 'EDIT',
};
export const ObjectType = {
TABLE: 'TABLE',
AREA: 'AREA',
NOTE: 'NOTE',
RELATIONSHIP: 'RELATIONSHIP',
TYPE: 'TYPE',
ENUM: 'ENUM',
DBML: 'DBML',
};
These enums ensure consistent payload shapes across the entire codebase.
How Actions Are Recorded
When any component modifies the diagram, it pushes an action object onto undoStack and clears redoStack (new branch in history). The standard action shape is:
{
action: Action.ADD | Action.MOVE | Action.DELETE | Action.EDIT,
element: ObjectType.TABLE | ObjectType.AREA | ...,
id: <elementIdentifier>, // table ID, note ID, etc.
data: <forwardPayload>, // data to re-apply (for redo)
undo: <reversePayload>, // data to revert (for undo)
bulk: <boolean>, // true = batch operation
elements: [<subActions>], // used when bulk is true
// element-specific fields: x, y, tid, fid, rid, aid, nid, etc.
}
Recording Example: Adding a Table
Components that add tables push actions like this:
const { setUndoStack } = useUndoRedo();
const addNewTable = (table) => {
// ... actual table creation logic ...
setUndoStack(prev => [
...prev,
{
action: Action.ADD,
element: ObjectType.TABLE,
data: table, // full table definition for redo
},
]);
};
Recording Example: Moving a Table
Moves store the previous position as the undo payload:
const moveTable = (id, newX, newY) => {
const table = tables.find(t => t.id === id);
const oldPos = { x: table.x, y: table.y };
updateTable(id, { x: newX, y: newY });
setUndoStack(prev => [
...prev,
{
action: Action.MOVE,
element: ObjectType.TABLE,
id,
x: oldPos.x, // stored for undo
y: oldPos.y,
},
]);
};
Undo Implementation in ControlPanel.jsx
The actual undo logic resides in src/components/EditorHeader/ControlPanel.jsx. This function inspects the last action, performs the inverse operation, and transfers the action to redoStack.
const undo = () => {
if (undoStack.length === 0) return;
const a = undoStack[undoStack.length - 1];
// Remove from undo stack
setUndoStack(prev => prev.filter((_, i) => i !== prev.length - 1));
// Handle bulk operations (multiple elements changed at once)
if (a.bulk) {
for (const element of a.elements) {
if (element.type === ObjectType.TABLE) {
updateTable(element.id, element.undo);
} else if (element.type === ObjectType.AREA) {
updateArea(element.id, element.undo);
} else if (element.type === ObjectType.NOTE) {
updateNote(element.id, element.undo);
}
}
setRedoStack(prev => [...prev, a]);
return;
}
// Handle DBML snapshot swaps
if (a.element === ObjectType.DBML) {
setRedoStack(prev => [...prev, swapDbmlSnapshot(a)]);
return;
}
// Simple actions: ADD, MOVE, DELETE, EDIT
if (a.action === Action.ADD) {
// Undo addition → delete the created element
if (a.element === ObjectType.TABLE) {
deleteTable(a.data.table.id, false);
} else if (a.element === ObjectType.AREA) {
deleteArea(areas[areas.length - 1].id, false);
} else if (a.element === ObjectType.NOTE) {
deleteNote(notes[notes.length - 1].id, false);
} else if (a.element === ObjectType.RELATIONSHIP) {
deleteRelationship(a.data.relationship.id, false);
}
// ... TYPE, ENUM handling ...
setRedoStack(prev => [...prev, a]);
} else if (a.action === Action.MOVE) {
// Undo move → restore previous coordinates
if (a.element === ObjectType.TABLE) {
const { x, y } = tables.find(t => t.id === a.id);
setRedoStack(prev => [...prev, { ...a, x, y }]);
updateTable(a.id, { x: a.x, y: a.y });
}
// ... AREA, NOTE handling ...
} else if (a.action === Action.DELETE) {
// Undo deletion → re-add the removed element
if (a.element === ObjectType.TABLE) {
a.data.relationship.forEach(r => addRelationship(r, false));
addTable(a.data, false);
}
// ... other element types ...
setRedoStack(prev => [...prev, a]);
} else if (a.action === Action.EDIT) {
// Undo edit → apply stored reverse payload
if (a.element === ObjectType.TABLE) {
if (a.component === "field") {
updateField(a.tid, a.fid, a.undo);
}
// ... other components: field_add, index, unique_constraint, etc.
} else if (a.element === ObjectType.RELATIONSHIP) {
updateRelationship(a.rid, a.undo);
}
// ... AREA, NOTE, TYPE, ENUM handling ...
setRedoStack(prev => [...prev, a]);
}
};
Key implementation details:
falseparameter — prevents creating new undo entries during reversal (avoiding infinite loops)- Coordinate preservation — moves capture current position before restoring old position, enabling redo
- Relationship handling — table deletions store associated relationships, which are restored together
Redo Implementation: Mirror of Undo
The redo function in the same file applies forward operations from redoStack back onto undoStack:
const redo = () => {
if (redoStack.length === 0) return;
const a = redoStack[redoStack.length - 1];
setRedoStack(prev => prev.filter((_, i) => i !== prev.length - 1));
if (a.bulk) {
a.elements.forEach(el => {
if (el.type === ObjectType.TABLE) addTable(el.data, false);
if (el.type === ObjectType.AREA) addArea(el.data, false);
if (el.type === ObjectType.NOTE) addNote(el.data, false);
});
setUndoStack(prev => [...prev, a]);
return;
}
if (a.element === ObjectType.DBML) {
setUndoStack(prev => [...prev, swapDbmlSnapshot(a)]);
return;
}
if (a.action === Action.ADD) {
if (a.element === ObjectType.TABLE) addTable(a.data, false);
if (a.element === ObjectType.AREA) addArea(a.data, false);
if (a.element === ObjectType.NOTE) addNote(a.data, false);
setUndoStack(prev => [...prev, a]);
} else if (a.action === Action.MOVE) {
if (a.element === ObjectType.TABLE) {
updateTable(a.id, { x: a.x, y: a.y });
}
// ... AREA, NOTE ...
setUndoStack(prev => [...prev, a]);
} else if (a.action === Action.DELETE) {
if (a.element === ObjectType.TABLE) deleteTable(a.data.id, false);
setUndoStack(prev => [...prev, a]);
} else if (a.action === Action.EDIT) {
if (a.element === ObjectType.TABLE) {
if (a.component === "field") updateField(a.tid, a.fid, a.data);
}
setUndoStack(prev => [...prev, a]);
}
};
Notice the symmetry: undo uses a.undo, redo uses a.data (or a.x, a.y for moves).
Integration with the UI
The undo/redo buttons in ControlPanel.jsx wire directly to these functions:
import { useUndoRedo } from '../../hooks/useUndoRedo';
export default function ControlPanel() {
const { undoStack, redoStack } = useUndoRedo();
return (
<>
<button
onClick={undo}
disabled={undoStack.length === 0}
>
Undo
</button>
<button
onClick={redo}
disabled={redoStack.length === 0}
>
Redo
</button>
</>
);
}
Special Cases: Bulk Operations and DBML Snapshots
Bulk actions handle multiple elements in one history entry. The elements array contains sub-actions with their own undo payloads, processed in sequence.
DBML snapshots use swapDbmlSnapshot(a), which exchanges the current diagram state with a stored previous state—treated as atomic operations in the history.
Summary
- drawDB implements undo/redo through two React state arrays (
undoStack,redoStack) provided byUndoRedoContextinsrc/context/UndoRedoContext.jsx - Every edit creates an immutable action object with standardized fields from
ActionandObjectTypeenums defined insrc/data/constants.js - The
undoandredofunctions insrc/components/EditorHeader/ControlPanel.jsxinterpret action types to apply inverse or forward operations using diagram-mutating APIs - Stack transfer is atomic: popping from one stack always pushes to the opposite, preserving history continuity
- Special handling exists for bulk edits, DBML snapshots, and relationship restoration during table deletions
Frequently Asked Questions
What data structure does drawDB use for undo/redo history?
DrawDB uses two simple JavaScript arrays managed through React state. The undoStack stores completed actions; the redoStack stores actions that were undone. This stack-based approach keeps the implementation straightforward while supporting infinite traversal through edit history.
Where is the undo/redo logic actually implemented?
The core logic lives in src/components/EditorHeader/ControlPanel.jsx, specifically in the undo() and redo() functions. These functions consume the UndoRedoContext from src/context/UndoRedoContext.jsx and invoke diagram-mutating utilities like deleteTable(), updateField(), and addRelationship().
How does drawDB prevent creating new history entries when undoing?
All diagram-mutation functions accept a false parameter as their last argument. When false, these functions skip pushing to the undo stack. The undo and redo functions pass false to every mutation, ensuring that reversal operations don't generate duplicate history entries.
Can drawDB undo complex operations like bulk table moves?
Yes. DrawDB's action object includes a bulk boolean and elements array for batch operations. When bulk is true, the undo/redo functions iterate through all sub-elements and apply the appropriate inverse or forward operation to each, treating the entire batch as single history entry.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →