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 ? ( +
+

No history yet

+
+ ) : ( + <> + {/* Undo stack (most recent first) */} + {[...undoStack].reverse().map((command, index) => ( +
+

{command.name}

+

+ {new Date(command.timestamp).toLocaleTimeString()} +

+
+ ))} + + {/* Current state indicator */} + {undoStack.length > 0 && ( +
+
+
Current State
+
+ )} + + {/* 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';