// 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;
}[];
}
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
}
// 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);
}
// 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,
},
}));
}
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>
);
}
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 ✅
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.