111
LVL 04 — SENIOR-IN-TRAININGSESSION 111DAY 111

HISTORY PANEL

🎫 PIXELCRAFT-097
Feature | 🔴 Expert | Priority: 🟠 High

Undo/redo works but users can't see where they've been. They want Photoshop-style history: see every action, click to jump to any point, branch from any state. Build a visual history timeline with thumbnails, branching, and IndexedDB persistence for large states.
CONCEPTS.UNLOCKED
📋
Operation Log
Record every action as a named entry. Not just state snapshots but labeled operations: "Brightness +20", "Crop to 800×600", "Apply Sepia". Users see WHAT they did, not just a numbered list.
🖼️
Visual Diffs
Thumbnail preview of each state. Generate a small canvas thumbnail after each operation. Users visually scan the timeline to find the state they want. A picture is worth a thousand undo presses.
🌿
Branching History
Undo 5 steps, make a different edit → branch. Instead of losing the original future, keep both paths. Like Git branches for image editing. Jump between branches at will.
📸
Snapshots
Named bookmarks in the timeline. "Before color correction", "After crop". Jump directly to named snapshots instead of scrolling through 50 unnamed states.
🗜️
History Compression
Keep recent states full, compress old ones. Last 20 states: full ImageData. Older: thumbnails only (recreate by replaying operations). Balances memory with accessibility.
💾
IndexedDB Storage
Large binary data doesn't fit in memory forever. IndexedDB stores ImageData blobs client-side. Survives page refresh. Asynchronous — doesn't block the main thread while saving.
HANDS-ON.TASKS
01
History Entry Type
// types/history.ts interface HistoryEntry { id: string; label: string; // e.g. "Brightness +20" timestamp: number; thumbnail: string; // base64 small preview parentId: string | null; // for branching branchId: string; snapshotName?: string; // optional bookmark } interface HistoryState { entries: HistoryEntry[]; currentId: string | null; branches: { id: string; name: string; rootEntryId: string; }[]; }
02
Thumbnail Generation
function generateThumbnail( imageData: ImageData, maxSize = 80 ): string { const canvas = document.createElement('canvas'); const scale = Math.min( maxSize / imageData.width, maxSize / imageData.height ); canvas.width = imageData.width * scale; canvas.height = imageData.height * scale; const ctx = canvas.getContext('2d')!; // Draw full image to temp canvas const temp = document.createElement('canvas'); temp.width = imageData.width; temp.height = imageData.height; temp.getContext('2d')! .putImageData(imageData, 0, 0); // Scale down ctx.drawImage(temp, 0, 0, canvas.width, canvas.height); return canvas.toDataURL( 'image/jpeg', 0.6); // ~2-5KB per thumbnail }
03
IndexedDB for Large States
// lib/historyDB.ts const DB_NAME = 'pixelcraft-history'; const STORE = 'states'; async function openDB(): Promise<IDBDatabase> { return new Promise((resolve, reject) => { const req = indexedDB.open( DB_NAME, 1); req.onupgradeneeded = () => { req.result .createObjectStore(STORE); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function saveState( id: string, imageData: ImageData ) { const db = await openDB(); const tx = db.transaction( STORE, 'readwrite'); // Store raw pixel buffer tx.objectStore(STORE).put( imageData.data.buffer, id); await tx.complete; } async function loadState( id: string, width: number, height: number ): Promise<ImageData> { const db = await openDB(); const tx = db.transaction( STORE, 'readonly'); const buffer = await new Promise( (resolve) => { const req = tx.objectStore(STORE) .get(id); req.onsuccess = () => resolve(req.result); }); return new ImageData( new Uint8ClampedArray( buffer as ArrayBuffer), width, height); }
04
Branching Logic
// When user undoes, then makes // a new edit → create a branch function pushAction( label: string, imageData: ImageData ) { const state = useEditorStore .getState().history; const current = state.currentId; // Check if current entry has // future children (we're not // at the tip) const currentEntry = state.entries.find( e => e.id === current); const hasChildren = state.entries.some( e => e.parentId === current); let branchId = currentEntry?.branchId ?? 'main'; if (hasChildren) { // Branching! Create new branch branchId = crypto.randomUUID(); useEditorStore.setState(s => ({ history: { ...s.history, branches: [ ...s.history.branches, { id: branchId, name: `Branch ${ s.history.branches.length + 1}`, rootEntryId: current! } ], }, })); } const entry: HistoryEntry = { id: crypto.randomUUID(), label, timestamp: Date.now(), thumbnail: generateThumbnail(imageData), parentId: current, branchId, }; // Save full state to IndexedDB saveState(entry.id, imageData); // Add entry, set as current useEditorStore.setState(s => ({ history: { ...s.history, entries: [ ...s.history.entries, entry], currentId: entry.id, }, })); }
05
History Panel Component
function HistoryPanel() { const { entries, currentId } = useEditorStore( s => s.history); const jumpTo = async (id: string) => { const entry = entries.find( e => e.id === id)!; const imageData = await loadState( id, canvas.width, canvas.height); restoreImage(imageData); useEditorStore.setState(s => ({ history: { ...s.history, currentId: id, }, })); }; return ( <aside className="history-panel"> <h3>History</h3> {entries.map(entry => ( <button key={entry.id} onClick={() => jumpTo(entry.id)} className={ entry.id === currentId ? 'active' : ''}> <img src={entry.thumbnail} alt={entry.label} /> <span>{entry.label}</span> <time>{formatTime( entry.timestamp)}</time> </button> ))} </aside> ); }
06
Close the Ticket
git switch -c feature/PIXELCRAFT-097-history-panel git add src/components/HistoryPanel/ src/lib/historyDB.ts git commit -m "Add visual history panel with branching (PIXELCRAFT-097)" git push origin feature/PIXELCRAFT-097-history-panel # PR → Review → Merge → Close ticket ✅
CS.DEEP-DIVE

History is a tree, not a list.

Linear undo (a stack) loses information when you branch. Tree-based history preserves every path — the same structure Git uses for version control.

// Linear history (stack):
A → B → C → D
  Undo to B, edit E:
A → B → E  (C, D lost forever)

// Tree history (PixelCraft):
A → B → C → D  (branch: main)
     └→ E → F  (branch: alt)
  Nothing is lost. Jump anywhere.

// Same structure as Git:
Git: commits form a DAG
  Branches are pointers
  HEAD is the current commit

PixelCraft: entries form a tree
  Branches are paths
  currentId is the active state

// Memory strategy:
Recent 20: full ImageData in IDB
Older: thumbnails only
  (replay operations to restore)
// Same as Git's packfiles: recent
// objects are loose, old ones packed.
"History Lab"
[A]Add named snapshots: right-click an entry → "Create Snapshot" → name it "Before color grading". Snapshots appear as bookmarks in the timeline. Jump to them from a dropdown. Like Git tags.
[B]Add history compression: when history exceeds 50 entries, compress entries older than 20 — delete their IndexedDB state, keep only thumbnails + labels. On restore, replay operations from the nearest full state.
[C]Research: how does Photoshop implement its History panel? What about Figma's version history? Compare their approaches to branching, compression, and collaboration. Write a design document.
REF.MATERIAL
ARTICLE
MDN Web Docs
Official IndexedDB guide: databases, object stores, transactions, cursors. The browser's built-in database for large structured data.
INDEXEDDBOFFICIALESSENTIAL
VIDEO
The Coding Train
Implementing undo/redo: command pattern, history stack, and state management. Building a visual history from scratch.
UNDOTUTORIAL
ARTICLE
MDN Web Docs
getImageData, putImageData, and working with raw pixel data. The foundation for saving and restoring canvas state.
CANVASPIXELS
ARTICLE
Jake Archibald
Promise-based IndexedDB wrapper: clean API, TypeScript support, much less boilerplate than raw IndexedDB. The recommended library.
INDEXEDDBLIBRARY
VIDEO
Computerphile
Git's object model: blobs, trees, commits. The same tree-based history model PixelCraft's branching history uses.
GITINTERNALS
// LEAVE EXCITED BECAUSE
Every action has a thumbnail and a name. Click any point to jump back. Undo and branch — nothing is lost. IndexedDB persists across refreshes. This is Photoshop-grade history.