diff --git a/components/editor/editor-layout.tsx b/components/editor/editor-layout.tsx
index 62c2d81..268d508 100644
--- a/components/editor/editor-layout.tsx
+++ b/components/editor/editor-layout.tsx
@@ -2,17 +2,27 @@
import { useEffect } from 'react';
import { useCanvasStore, useLayerStore } from '@/store';
+import { useHistoryStore } from '@/store/history-store';
import { CanvasWrapper } from '@/components/canvas/canvas-wrapper';
import { LayersPanel } from '@/components/layers/layers-panel';
-import { Plus, ZoomIn, ZoomOut, Maximize } from 'lucide-react';
+import { HistoryPanel } from './history-panel';
+import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
+import { createLayerWithHistory } from '@/lib/layer-operations';
+import { Plus, ZoomIn, ZoomOut, Maximize, Undo, Redo } from 'lucide-react';
+import { cn } from '@/lib/utils';
export function EditorLayout() {
- const { zoom, zoomIn, zoomOut, zoomToFit, setDimensions } = useCanvasStore();
- const { createLayer, layers } = useLayerStore();
+ const { zoom, zoomIn, zoomOut, zoomToFit } = useCanvasStore();
+ const { layers } = useLayerStore();
+ const { undo, redo, canUndo, canRedo } = useHistoryStore();
- // Initialize with a default layer
+ // Enable keyboard shortcuts
+ useKeyboardShortcuts();
+
+ // Initialize with a default layer (without history)
useEffect(() => {
if (layers.length === 0) {
+ const { createLayer } = useLayerStore.getState();
createLayer({
name: 'Background',
width: 800,
@@ -23,7 +33,7 @@ export function EditorLayout() {
}, []);
const handleNewLayer = () => {
- createLayer({
+ createLayerWithHistory({
name: `Layer ${layers.length + 1}`,
width: 800,
height: 600,
@@ -47,6 +57,37 @@ export function EditorLayout() {
+ {/* History controls */}
+
+
+
+
+
+
{/* Zoom controls */}
diff --git a/components/editor/history-panel.tsx b/components/editor/history-panel.tsx
new file mode 100644
index 0000000..c14c806
--- /dev/null
+++ b/components/editor/history-panel.tsx
@@ -0,0 +1,70 @@
+'use client';
+
+import { useHistoryStore } from '@/store/history-store';
+import { History } from 'lucide-react';
+
+export function HistoryPanel() {
+ const { undoStack, redoStack } = useHistoryStore();
+
+ return (
+
+
+
+
+ History
+
+
+
+
+ {undoStack.length === 0 && redoStack.length === 0 ? (
+
+ ) : (
+ <>
+ {/* Undo stack (most recent first) */}
+ {[...undoStack].reverse().map((command, index) => (
+
+
{command.name}
+
+ {new Date(command.timestamp).toLocaleTimeString()}
+
+
+ ))}
+
+ {/* Current state indicator */}
+ {undoStack.length > 0 && (
+
+ )}
+
+ {/* Redo stack (oldest first) */}
+ {[...redoStack].reverse().map((command, index) => (
+
+
{command.name}
+
+ {new Date(command.timestamp).toLocaleTimeString()}
+
+
+ ))}
+ >
+ )}
+
+
+
+
+ {undoStack.length} undoable
+ {redoStack.length} redoable
+
+
+
+ );
+}
diff --git a/components/editor/index.ts b/components/editor/index.ts
index b551a6f..880992f 100644
--- a/components/editor/index.ts
+++ b/components/editor/index.ts
@@ -1 +1,2 @@
export * from './editor-layout';
+export * from './history-panel';
diff --git a/components/layers/layers-panel.tsx b/components/layers/layers-panel.tsx
index 0936286..1fe246a 100644
--- a/components/layers/layers-panel.tsx
+++ b/components/layers/layers-panel.tsx
@@ -1,11 +1,16 @@
'use client';
import { useLayerStore } from '@/store';
-import { Eye, EyeOff, Trash2 } from 'lucide-react';
+import { Eye, EyeOff, Trash2, Copy } from 'lucide-react';
import { cn } from '@/lib/utils';
+import {
+ deleteLayerWithHistory,
+ updateLayerWithHistory,
+ duplicateLayerWithHistory,
+} from '@/lib/layer-operations';
export function LayersPanel() {
- const { layers, activeLayerId, setActiveLayer, updateLayer, deleteLayer } = useLayerStore();
+ const { layers, activeLayerId, setActiveLayer } = useLayerStore();
// Sort layers by order (highest first)
const sortedLayers = [...layers].sort((a, b) => b.order - a.order);
@@ -37,8 +42,9 @@ export function LayersPanel() {
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
- updateLayer(layer.id, { visible: !layer.visible });
+ updateLayerWithHistory(layer.id, { visible: !layer.visible }, 'Toggle Visibility');
}}
+ title="Toggle visibility"
>
{layer.visible ? (
@@ -57,14 +63,25 @@ export function LayersPanel() {
+
diff --git a/core/commands/base-command.ts b/core/commands/base-command.ts
new file mode 100644
index 0000000..9a02149
--- /dev/null
+++ b/core/commands/base-command.ts
@@ -0,0 +1,22 @@
+import type { Command } from '@/types/history';
+
+/**
+ * Abstract base class for commands
+ */
+export abstract class BaseCommand implements Command {
+ public timestamp: number;
+
+ constructor(public name: string) {
+ this.timestamp = Date.now();
+ }
+
+ abstract execute(): void;
+ abstract undo(): void;
+
+ /**
+ * Override to implement command merging
+ */
+ merge(other: Command): boolean {
+ return false;
+ }
+}
diff --git a/core/commands/index.ts b/core/commands/index.ts
new file mode 100644
index 0000000..f3066c5
--- /dev/null
+++ b/core/commands/index.ts
@@ -0,0 +1,2 @@
+export * from './base-command';
+export * from './layer-commands';
diff --git a/core/commands/layer-commands.ts b/core/commands/layer-commands.ts
new file mode 100644
index 0000000..d780642
--- /dev/null
+++ b/core/commands/layer-commands.ts
@@ -0,0 +1,247 @@
+import { BaseCommand } from './base-command';
+import { useLayerStore } from '@/store';
+import type { Layer, LayerUpdate, CreateLayerParams } from '@/types';
+
+/**
+ * Command to create a new layer
+ */
+export class CreateLayerCommand extends BaseCommand {
+ private layerId: string | null = null;
+ private layer: Layer | null = null;
+
+ constructor(private params: CreateLayerParams) {
+ super('Create Layer');
+ }
+
+ execute(): void {
+ const store = useLayerStore.getState();
+ this.layer = store.createLayer(this.params);
+ this.layerId = this.layer.id;
+ }
+
+ undo(): void {
+ if (!this.layerId) return;
+ const store = useLayerStore.getState();
+ store.deleteLayer(this.layerId);
+ }
+}
+
+/**
+ * Command to delete a layer
+ */
+export class DeleteLayerCommand extends BaseCommand {
+ private layer: Layer | null = null;
+ private wasActive: boolean = false;
+
+ constructor(private layerId: string) {
+ super('Delete Layer');
+ }
+
+ execute(): void {
+ const store = useLayerStore.getState();
+ this.layer = store.getLayer(this.layerId) || null;
+ this.wasActive = store.activeLayerId === this.layerId;
+
+ if (this.layer) {
+ store.deleteLayer(this.layerId);
+ }
+ }
+
+ undo(): void {
+ if (!this.layer) return;
+
+ const store = useLayerStore.getState();
+ const layers = [...store.layers];
+
+ // Re-insert layer at its original position
+ layers.splice(this.layer.order, 0, this.layer);
+
+ // Update order for all layers
+ const reorderedLayers = layers.map((l, index) => ({
+ ...l,
+ order: index,
+ }));
+
+ // Manually update the store
+ useLayerStore.setState({
+ layers: reorderedLayers,
+ activeLayerId: this.wasActive ? this.layerId : store.activeLayerId,
+ });
+ }
+}
+
+/**
+ * Command to update layer properties
+ */
+export class UpdateLayerCommand extends BaseCommand {
+ private oldValues: LayerUpdate;
+ private newValues: LayerUpdate;
+
+ constructor(
+ private layerId: string,
+ updates: LayerUpdate,
+ commandName?: string
+ ) {
+ super(commandName || 'Update Layer');
+
+ const store = useLayerStore.getState();
+ const layer = store.getLayer(layerId);
+
+ if (!layer) {
+ throw new Error(`Layer ${layerId} not found`);
+ }
+
+ // Store old values
+ this.oldValues = {};
+ this.newValues = updates;
+
+ Object.keys(updates).forEach((key) => {
+ this.oldValues[key as keyof LayerUpdate] = layer[key as keyof Layer] as any;
+ });
+ }
+
+ execute(): void {
+ const store = useLayerStore.getState();
+ store.updateLayer(this.layerId, this.newValues);
+ }
+
+ undo(): void {
+ const store = useLayerStore.getState();
+ store.updateLayer(this.layerId, this.oldValues);
+ }
+
+ /**
+ * Merge consecutive updates to the same layer
+ */
+ merge(other: Command): boolean {
+ if (!(other instanceof UpdateLayerCommand)) return false;
+ if (other.layerId !== this.layerId) return false;
+
+ // Merge within 1 second
+ if (other.timestamp - this.timestamp > 1000) return false;
+
+ // Update new values with the latest changes
+ Object.assign(this.newValues, other.newValues);
+ this.timestamp = other.timestamp;
+
+ return true;
+ }
+}
+
+/**
+ * Command to duplicate a layer
+ */
+export class DuplicateLayerCommand extends BaseCommand {
+ private newLayerId: string | null = null;
+
+ constructor(private sourceLayerId: string) {
+ super('Duplicate Layer');
+ }
+
+ execute(): void {
+ const store = useLayerStore.getState();
+ const newLayer = store.duplicateLayer(this.sourceLayerId);
+ this.newLayerId = newLayer?.id || null;
+ }
+
+ undo(): void {
+ if (!this.newLayerId) return;
+ const store = useLayerStore.getState();
+ store.deleteLayer(this.newLayerId);
+ }
+}
+
+/**
+ * Command to reorder a layer
+ */
+export class ReorderLayerCommand extends BaseCommand {
+ private oldOrder: number;
+ private newOrder: number;
+
+ constructor(private layerId: string, newOrder: number) {
+ super('Reorder Layer');
+
+ const store = useLayerStore.getState();
+ const layer = store.getLayer(layerId);
+
+ if (!layer) {
+ throw new Error(`Layer ${layerId} not found`);
+ }
+
+ this.oldOrder = layer.order;
+ this.newOrder = newOrder;
+ }
+
+ execute(): void {
+ const store = useLayerStore.getState();
+ store.reorderLayer(this.layerId, this.newOrder);
+ }
+
+ undo(): void {
+ const store = useLayerStore.getState();
+ store.reorderLayer(this.layerId, this.oldOrder);
+ }
+}
+
+/**
+ * Command to merge layer down
+ */
+export class MergeLayerDownCommand extends BaseCommand {
+ private topLayer: Layer | null = null;
+ private bottomLayerBefore: HTMLCanvasElement | null = null;
+ private bottomLayerId: string | null = null;
+
+ constructor(private layerId: string) {
+ super('Merge Down');
+ }
+
+ execute(): void {
+ const store = useLayerStore.getState();
+ const layers = store.layers;
+ const layerIndex = layers.findIndex((l) => l.id === this.layerId);
+
+ if (layerIndex === -1 || layerIndex === 0) return;
+
+ this.topLayer = { ...layers[layerIndex] };
+ const bottomLayer = layers[layerIndex - 1];
+ this.bottomLayerId = bottomLayer.id;
+
+ // Clone bottom layer canvas before merge
+ if (bottomLayer.canvas) {
+ const canvas = document.createElement('canvas');
+ canvas.width = bottomLayer.canvas.width;
+ canvas.height = bottomLayer.canvas.height;
+ const ctx = canvas.getContext('2d');
+ if (ctx) {
+ ctx.drawImage(bottomLayer.canvas, 0, 0);
+ }
+ this.bottomLayerBefore = canvas;
+ }
+
+ store.mergeDown(this.layerId);
+ }
+
+ undo(): void {
+ if (!this.topLayer || !this.bottomLayerId || !this.bottomLayerBefore) return;
+
+ const store = useLayerStore.getState();
+
+ // Restore bottom layer canvas
+ const bottomLayer = store.getLayer(this.bottomLayerId);
+ if (bottomLayer && bottomLayer.canvas) {
+ const ctx = bottomLayer.canvas.getContext('2d');
+ if (ctx) {
+ ctx.clearRect(0, 0, bottomLayer.canvas.width, bottomLayer.canvas.height);
+ ctx.drawImage(this.bottomLayerBefore, 0, 0);
+ }
+ }
+
+ // Re-create top layer
+ const layers = [...store.layers];
+ layers.splice(this.topLayer.order, 0, this.topLayer);
+
+ useLayerStore.setState({
+ layers: layers.map((l, index) => ({ ...l, order: index })),
+ });
+ }
+}
diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts
new file mode 100644
index 0000000..1f9520b
--- /dev/null
+++ b/hooks/use-keyboard-shortcuts.ts
@@ -0,0 +1,123 @@
+import { useEffect } from 'react';
+import { useHistoryStore } from '@/store/history-store';
+
+/**
+ * Keyboard shortcuts configuration
+ */
+interface KeyboardShortcut {
+ key: string;
+ ctrl?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+ meta?: boolean;
+ handler: () => void;
+ description: string;
+}
+
+/**
+ * Hook to manage keyboard shortcuts
+ */
+export function useKeyboardShortcuts() {
+ const { undo, redo, canUndo, canRedo } = useHistoryStore();
+
+ useEffect(() => {
+ const shortcuts: KeyboardShortcut[] = [
+ {
+ key: 'z',
+ ctrl: true,
+ shift: false,
+ handler: () => {
+ if (canUndo()) {
+ undo();
+ }
+ },
+ description: 'Undo',
+ },
+ {
+ key: 'z',
+ ctrl: true,
+ shift: true,
+ handler: () => {
+ if (canRedo()) {
+ redo();
+ }
+ },
+ description: 'Redo',
+ },
+ {
+ key: 'y',
+ ctrl: true,
+ handler: () => {
+ if (canRedo()) {
+ redo();
+ }
+ },
+ description: 'Redo (alternative)',
+ },
+ ];
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // Check if we're in an input field
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+
+ for (const shortcut of shortcuts) {
+ const ctrlMatch = shortcut.ctrl ? (e.ctrlKey || e.metaKey) : !e.ctrlKey && !e.metaKey;
+ const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
+ const altMatch = shortcut.alt ? e.altKey : !e.altKey;
+
+ if (
+ e.key.toLowerCase() === shortcut.key.toLowerCase() &&
+ ctrlMatch &&
+ shiftMatch &&
+ altMatch
+ ) {
+ e.preventDefault();
+ shortcut.handler();
+ break;
+ }
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [undo, redo, canUndo, canRedo]);
+}
+
+/**
+ * Get keyboard shortcut display string
+ */
+export function getShortcutDisplay(shortcut: {
+ key: string;
+ ctrl?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+ meta?: boolean;
+}): string {
+ const parts: string[] = [];
+
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.platform);
+
+ if (shortcut.ctrl || shortcut.meta) {
+ parts.push(isMac ? '⌘' : 'Ctrl');
+ }
+ if (shortcut.shift) {
+ parts.push(isMac ? '⇧' : 'Shift');
+ }
+ if (shortcut.alt) {
+ parts.push(isMac ? '⌥' : 'Alt');
+ }
+
+ parts.push(shortcut.key.toUpperCase());
+
+ return parts.join(isMac ? '' : '+');
+}
diff --git a/lib/layer-operations.ts b/lib/layer-operations.ts
new file mode 100644
index 0000000..088a27e
--- /dev/null
+++ b/lib/layer-operations.ts
@@ -0,0 +1,67 @@
+/**
+ * Layer operations that integrate with history system
+ * Use these instead of calling store methods directly
+ */
+
+import { useHistoryStore } from '@/store/history-store';
+import {
+ CreateLayerCommand,
+ DeleteLayerCommand,
+ UpdateLayerCommand,
+ DuplicateLayerCommand,
+ ReorderLayerCommand,
+ MergeLayerDownCommand,
+} from '@/core/commands';
+import type { CreateLayerParams, LayerUpdate } from '@/types';
+
+/**
+ * Create a new layer (with undo support)
+ */
+export function createLayerWithHistory(params: CreateLayerParams): void {
+ const command = new CreateLayerCommand(params);
+ useHistoryStore.getState().executeCommand(command);
+}
+
+/**
+ * Delete a layer (with undo support)
+ */
+export function deleteLayerWithHistory(layerId: string): void {
+ const command = new DeleteLayerCommand(layerId);
+ useHistoryStore.getState().executeCommand(command);
+}
+
+/**
+ * Update layer properties (with undo support)
+ */
+export function updateLayerWithHistory(
+ layerId: string,
+ updates: LayerUpdate,
+ commandName?: string
+): void {
+ const command = new UpdateLayerCommand(layerId, updates, commandName);
+ useHistoryStore.getState().executeCommand(command);
+}
+
+/**
+ * Duplicate a layer (with undo support)
+ */
+export function duplicateLayerWithHistory(layerId: string): void {
+ const command = new DuplicateLayerCommand(layerId);
+ useHistoryStore.getState().executeCommand(command);
+}
+
+/**
+ * Reorder a layer (with undo support)
+ */
+export function reorderLayerWithHistory(layerId: string, newOrder: number): void {
+ const command = new ReorderLayerCommand(layerId, newOrder);
+ useHistoryStore.getState().executeCommand(command);
+}
+
+/**
+ * Merge layer down (with undo support)
+ */
+export function mergeLayerDownWithHistory(layerId: string): void {
+ const command = new MergeLayerDownCommand(layerId);
+ useHistoryStore.getState().executeCommand(command);
+}
diff --git a/store/history-store.ts b/store/history-store.ts
new file mode 100644
index 0000000..2238b75
--- /dev/null
+++ b/store/history-store.ts
@@ -0,0 +1,135 @@
+import { create } from 'zustand';
+import type { Command, HistoryState } from '@/types/history';
+
+interface HistoryStore extends HistoryState {
+ /** Execute a command and add to history */
+ executeCommand: (command: Command) => void;
+ /** Undo the last command */
+ undo: () => void;
+ /** Redo the last undone command */
+ redo: () => void;
+ /** Clear all history */
+ clearHistory: () => void;
+ /** Check if can undo */
+ canUndo: () => boolean;
+ /** Check if can redo */
+ canRedo: () => boolean;
+ /** Get current state summary */
+ getHistorySummary: () => { undoCount: number; redoCount: number };
+}
+
+const MAX_HISTORY_SIZE = 50;
+
+export const useHistoryStore = create((set, get) => ({
+ undoStack: [],
+ redoStack: [],
+ maxHistorySize: MAX_HISTORY_SIZE,
+ isExecuting: false,
+
+ executeCommand: (command) => {
+ const state = get();
+
+ // Prevent recursive execution
+ if (state.isExecuting) return;
+
+ set({ isExecuting: true });
+
+ try {
+ // Try to merge with last command
+ const lastCommand = state.undoStack[state.undoStack.length - 1];
+ if (lastCommand && lastCommand.merge && lastCommand.merge(command)) {
+ // Command was merged, re-execute the merged command
+ lastCommand.execute();
+ set({ isExecuting: false });
+ return;
+ }
+
+ // Execute the new command
+ command.execute();
+
+ // Add to undo stack
+ const newUndoStack = [...state.undoStack, command];
+
+ // Limit stack size
+ if (newUndoStack.length > state.maxHistorySize) {
+ newUndoStack.shift();
+ }
+
+ set({
+ undoStack: newUndoStack,
+ redoStack: [], // Clear redo stack on new action
+ isExecuting: false,
+ });
+ } catch (error) {
+ console.error('Failed to execute command:', error);
+ set({ isExecuting: false });
+ }
+ },
+
+ undo: () => {
+ const state = get();
+
+ if (state.undoStack.length === 0 || state.isExecuting) return;
+
+ set({ isExecuting: true });
+
+ try {
+ const command = state.undoStack[state.undoStack.length - 1];
+ command.undo();
+
+ set({
+ undoStack: state.undoStack.slice(0, -1),
+ redoStack: [...state.redoStack, command],
+ isExecuting: false,
+ });
+ } catch (error) {
+ console.error('Failed to undo command:', error);
+ set({ isExecuting: false });
+ }
+ },
+
+ redo: () => {
+ const state = get();
+
+ if (state.redoStack.length === 0 || state.isExecuting) return;
+
+ set({ isExecuting: true });
+
+ try {
+ const command = state.redoStack[state.redoStack.length - 1];
+ command.execute();
+
+ set({
+ undoStack: [...state.undoStack, command],
+ redoStack: state.redoStack.slice(0, -1),
+ isExecuting: false,
+ });
+ } catch (error) {
+ console.error('Failed to redo command:', error);
+ set({ isExecuting: false });
+ }
+ },
+
+ clearHistory: () => {
+ set({
+ undoStack: [],
+ redoStack: [],
+ });
+ },
+
+ canUndo: () => {
+ return get().undoStack.length > 0;
+ },
+
+ canRedo: () => {
+ return get().redoStack.length > 0;
+ },
+
+ getHistorySummary: () => {
+ const state = get();
+ return {
+ undoCount: state.undoStack.length,
+ redoCount: state.redoStack.length,
+ };
+ },
+}));
diff --git a/store/index.ts b/store/index.ts
index e407a0c..8e63f31 100644
--- a/store/index.ts
+++ b/store/index.ts
@@ -1,3 +1,4 @@
export * from './canvas-store';
export * from './layer-store';
export * from './tool-store';
+export * from './history-store';
diff --git a/types/history.ts b/types/history.ts
new file mode 100644
index 0000000..2707f5c
--- /dev/null
+++ b/types/history.ts
@@ -0,0 +1,29 @@
+/**
+ * Base Command interface for undo/redo operations
+ */
+export interface Command {
+ /** Execute the command */
+ execute: () => void;
+ /** Undo the command */
+ undo: () => void;
+ /** Optional: merge with another command of same type */
+ merge?: (other: Command) => boolean;
+ /** Command name for display */
+ name: string;
+ /** Timestamp of execution */
+ timestamp: number;
+}
+
+/**
+ * History state interface
+ */
+export interface HistoryState {
+ /** Stack of executed commands */
+ undoStack: Command[];
+ /** Stack of undone commands */
+ redoStack: Command[];
+ /** Maximum number of commands to store */
+ maxHistorySize: number;
+ /** Is currently executing a command (prevent recursion) */
+ isExecuting: boolean;
+}
diff --git a/types/index.ts b/types/index.ts
index 76b1f39..bf2e297 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -1,3 +1,4 @@
export * from './canvas';
export * from './layer';
export * from './tool';
+export * from './history';