vit-design-webapp / src / lib / store / editor-store.ts Blame
507 lines
65beb53 samthecodingguy Jan 29, 2026
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import { v4 as uuidv4 } from 'uuid';
import type {
  Layer,
  CanvasConfig,
  Tool,
  HistoryEntry,
  ExportPreset,
  EXPORT_PRESETS,
  BrandKit,
  GradientConfig,
} from '@/types';
interface EditorState {
  // Canvas
  canvas: CanvasConfig;
  // Layers
  layers: Layer[];
  selectedLayerIds: string[];
  // Tools
  activeTool: Tool;
  // History
  history: HistoryEntry[];
  historyIndex: number;
  maxHistoryLength: number;
  // UI State
  sidebarTab: 'devices' | 'templates' | 'text' | 'shapes' | 'images' | 'stock' | 'effects' | 'adjustments' | 'backgrounds' | 'export' | 'brand';
  showGrid: boolean;
  showGuides: boolean;
  snapToGrid: boolean;
  // Brand Kit
  brandKit: BrandKit | null;
  // Actions
  setCanvas: (canvas: Partial<CanvasConfig>) => void;
  setPreset: (preset: ExportPreset) => void;
  setBackgroundColor: (color: string | GradientConfig) => void;
  setBackgroundImage: (src: string | undefined) => void;
  setZoom: (zoom: number) => void;
  setPan: (x: number, y: number) => void;
  // Layer actions
  addLayer: (layer: Omit<Layer, 'id'>) => string;
  updateLayer: (id: string, updates: Partial<Layer>) => void;
  updateLayerData: (id: string, dataUpdates: Record<string, unknown>) => void;
  deleteLayer: (id: string) => void;
  duplicateLayer: (id: string) => void;
  moveLayer: (id: string, direction: 'up' | 'down' | 'top' | 'bottom') => void;
  selectLayer: (id: string, addToSelection?: boolean) => void;
  deselectAll: () => void;
  // Alignment actions
  alignLayers: (alignment: 'left' | 'center' | 'right' | 'top' | 'middle' | 'bottom') => void;
  distributeLayers: (direction: 'horizontal' | 'vertical') => void;
  // Tool actions
  setActiveTool: (tool: Tool) => void;
  // History actions
  saveToHistory: (action: string) => void;
  undo: () => void;
  redo: () => void;
  // UI actions
  setSidebarTab: (tab: EditorState['sidebarTab']) => void;
  toggleGrid: () => void;
  toggleGuides: () => void;
  toggleSnap: () => void;
  // Brand Kit actions
  setBrandKit: (brandKit: BrandKit | null) => void;
  // Project actions
  resetProject: () => void;
  loadProject: (canvas: CanvasConfig, layers: Layer[]) => void;
}
const PRESET_SIZES: Record<ExportPreset, { width: number; height: number }> = {
  'iphone-6.9': { width: 1260, height: 2736 },
  'iphone-6.5': { width: 1284, height: 2778 },
  'iphone-6.1': { width: 1179, height: 2556 },
  'ipad-13': { width: 2064, height: 2752 },
  'ipad-11': { width: 1668, height: 2420 },
  'mac': { width: 2880, height: 1800 },
  'instagram-post': { width: 1080, height: 1350 },
  'instagram-story': { width: 1080, height: 1920 },
  'twitter-post': { width: 1200, height: 675 },
  'linkedin-post': { width: 1200, height: 627 },
  'facebook-post': { width: 1200, height: 630 },
  'product-hunt': { width: 1270, height: 760 },
  'youtube-thumbnail': { width: 1280, height: 720 },
  'custom': { width: 1080, height: 1920 },
};
const initialCanvas: CanvasConfig = {
  width: 1284,
  height: 2778,
  zoom: 0.3,
  pan: { x: 0, y: 0 },
  backgroundColor: '#1a1a2e',
};
export const useEditorStore = create<EditorState>()(
  immer((set, get) => ({
    // Initial state
    canvas: initialCanvas,
    layers: [],
    selectedLayerIds: [],
    activeTool: 'select',
    history: [],
    historyIndex: -1,
    maxHistoryLength: 50,
    sidebarTab: 'templates',
    showGrid: false,
    showGuides: true,
    snapToGrid: true,
    brandKit: null,
    // Canvas actions
    setCanvas: (updates) => {
      set((state) => {
        Object.assign(state.canvas, updates);
      });
    },
    setPreset: (preset) => {
      const size = PRESET_SIZES[preset];
      set((state) => {
        state.canvas.width = size.width;
        state.canvas.height = size.height;
      });
      get().saveToHistory(`Changed preset to ${preset}`);
    },
    setBackgroundColor: (color) => {
      set((state) => {
        state.canvas.backgroundColor = color;
      });
      get().saveToHistory('Changed background color');
    },
    setBackgroundImage: (src) => {
      set((state) => {
        state.canvas.backgroundImage = src;
      });
      if (src) {
        get().saveToHistory('Added background image');
      }
    },
    setZoom: (zoom) => {
      set((state) => {
        state.canvas.zoom = Math.max(0.1, Math.min(3, zoom));
      });
    },
    setPan: (x, y) => {
      set((state) => {
        state.canvas.pan = { x, y };
      });
    },
    // Layer actions
    addLayer: (layerData) => {
      const id = uuidv4();
      set((state) => {
        state.layers.push({ ...layerData, id } as Layer);
        state.selectedLayerIds = [id];
      });
      get().saveToHistory(`Added ${layerData.type} layer`);
      return id;
    },
    updateLayer: (id, updates) => {
      set((state) => {
        const layer = state.layers.find((l) => l.id === id);
        if (layer) {
          Object.assign(layer, updates);
        }
      });
    },
    updateLayerData: (id, dataUpdates) => {
      set((state) => {
        const layer = state.layers.find((l) => l.id === id);
        if (layer && layer.data) {
          Object.assign(layer.data, dataUpdates);
        }
      });
    },
    deleteLayer: (id) => {
      set((state) => {
        state.layers = state.layers.filter((l) => l.id !== id);
        state.selectedLayerIds = state.selectedLayerIds.filter((i) => i !== id);
      });
      get().saveToHistory('Deleted layer');
    },
    duplicateLayer: (id) => {
      const layer = get().layers.find((l) => l.id === id);
      if (layer) {
        const newId = uuidv4();
        const newLayer = {
          ...JSON.parse(JSON.stringify(layer)),
          id: newId,
          name: `${layer.name} (copy)`,
          position: {
            x: layer.position.x + 20,
            y: layer.position.y + 20,
          },
        };
        set((state) => {
          const index = state.layers.findIndex((l) => l.id === id);
          state.layers.splice(index + 1, 0, newLayer);
          state.selectedLayerIds = [newId];
        });
        get().saveToHistory('Duplicated layer');
      }
    },
    moveLayer: (id, direction) => {
      set((state) => {
        const index = state.layers.findIndex((l) => l.id === id);
        if (index === -1) return;
        const layer = state.layers[index];
        state.layers.splice(index, 1);
        switch (direction) {
          case 'up':
            state.layers.splice(Math.min(index + 1, state.layers.length), 0, layer);
            break;
          case 'down':
            state.layers.splice(Math.max(index - 1, 0), 0, layer);
            break;
          case 'top':
            state.layers.push(layer);
            break;
          case 'bottom':
            state.layers.unshift(layer);
            break;
        }
      });
    },
    selectLayer: (id, addToSelection = false) => {
      set((state) => {
        if (addToSelection) {
          if (state.selectedLayerIds.includes(id)) {
            state.selectedLayerIds = state.selectedLayerIds.filter((i) => i !== id);
          } else {
            state.selectedLayerIds.push(id);
          }
        } else {
          state.selectedLayerIds = [id];
        }
      });
    },
    deselectAll: () => {
      set((state) => {
        state.selectedLayerIds = [];
      });
    },
    // Alignment actions
    alignLayers: (alignment) => {
      const { selectedLayerIds, layers, canvas } = get();
      if (selectedLayerIds.length < 1) return;
      const selectedLayers = selectedLayerIds
        .map((id) => layers.find((l) => l.id === id))
        .filter(Boolean) as typeof layers;
      if (selectedLayers.length === 0) return;
      // Calculate bounds of all selected layers
      const bounds = {
        left: Math.min(...selectedLayers.map((l) => l.position.x)),
        right: Math.max(...selectedLayers.map((l) => l.position.x + l.size.width)),
        top: Math.min(...selectedLayers.map((l) => l.position.y)),
        bottom: Math.max(...selectedLayers.map((l) => l.position.y + l.size.height)),
      };
      set((state) => {
        selectedLayers.forEach((selectedLayer) => {
          const layer = state.layers.find((l) => l.id === selectedLayer.id);
          if (!layer) return;
          switch (alignment) {
            case 'left':
              // If single layer, align to canvas; if multiple, align to leftmost
              layer.position.x = selectedLayers.length === 1 ? 0 : bounds.left;
              break;
            case 'center':
              if (selectedLayers.length === 1) {
                layer.position.x = (canvas.width - layer.size.width) / 2;
              } else {
                const groupCenter = (bounds.left + bounds.right) / 2;
                layer.position.x = groupCenter - layer.size.width / 2;
              }
              break;
            case 'right':
              if (selectedLayers.length === 1) {
                layer.position.x = canvas.width - layer.size.width;
              } else {
                layer.position.x = bounds.right - layer.size.width;
              }
              break;
            case 'top':
              layer.position.y = selectedLayers.length === 1 ? 0 : bounds.top;
              break;
            case 'middle':
              if (selectedLayers.length === 1) {
                layer.position.y = (canvas.height - layer.size.height) / 2;
              } else {
                const groupMiddle = (bounds.top + bounds.bottom) / 2;
                layer.position.y = groupMiddle - layer.size.height / 2;
              }
              break;
            case 'bottom':
              if (selectedLayers.length === 1) {
                layer.position.y = canvas.height - layer.size.height;
              } else {
                layer.position.y = bounds.bottom - layer.size.height;
              }
              break;
          }
        });
      });
      get().saveToHistory(`Aligned layers ${alignment}`);
    },
    distributeLayers: (direction) => {
      const { selectedLayerIds, layers } = get();
      if (selectedLayerIds.length < 3) return; // Need at least 3 to distribute
      const selectedLayers = selectedLayerIds
        .map((id) => layers.find((l) => l.id === id))
        .filter(Boolean) as typeof layers;
      if (selectedLayers.length < 3) return;
      if (direction === 'horizontal') {
        // Sort by x position
        const sorted = [...selectedLayers].sort((a, b) => a.position.x - b.position.x);
        const first = sorted[0];
        const last = sorted[sorted.length - 1];
        const totalWidth = sorted.reduce((sum, l) => sum + l.size.width, 0);
        const availableSpace = (last.position.x + last.size.width) - first.position.x - totalWidth;
        const gap = availableSpace / (sorted.length - 1);
        let currentX = first.position.x;
        set((state) => {
          sorted.forEach((selectedLayer) => {
            const layer = state.layers.find((l) => l.id === selectedLayer.id);
            if (layer) {
              layer.position.x = currentX;
              currentX += layer.size.width + gap;
            }
          });
        });
      } else {
        // Sort by y position
        const sorted = [...selectedLayers].sort((a, b) => a.position.y - b.position.y);
        const first = sorted[0];
        const last = sorted[sorted.length - 1];
        const totalHeight = sorted.reduce((sum, l) => sum + l.size.height, 0);
        const availableSpace = (last.position.y + last.size.height) - first.position.y - totalHeight;
        const gap = availableSpace / (sorted.length - 1);
        let currentY = first.position.y;
        set((state) => {
          sorted.forEach((selectedLayer) => {
            const layer = state.layers.find((l) => l.id === selectedLayer.id);
            if (layer) {
              layer.position.y = currentY;
              currentY += layer.size.height + gap;
            }
          });
        });
      }
      get().saveToHistory(`Distributed layers ${direction}ly`);
    },
    // Tool actions
    setActiveTool: (tool) => {
      set((state) => {
        state.activeTool = tool;
      });
    },
    // History actions
    saveToHistory: (action) => {
      set((state) => {
        const entry: HistoryEntry = {
          id: uuidv4(),
          timestamp: Date.now(),
          action,
          state: {
            layers: JSON.parse(JSON.stringify(state.layers)),
            canvas: JSON.parse(JSON.stringify(state.canvas)),
          },
        };
        // Remove any future history if we're not at the end
        if (state.historyIndex < state.history.length - 1) {
          state.history = state.history.slice(0, state.historyIndex + 1);
        }
        state.history.push(entry);
        state.historyIndex = state.history.length - 1;
        // Limit history length
        if (state.history.length > state.maxHistoryLength) {
          state.history.shift();
          state.historyIndex--;
        }
      });
    },
    undo: () => {
      const { history, historyIndex } = get();
      if (historyIndex > 0) {
        const prevEntry = history[historyIndex - 1];
        set((state) => {
          state.layers = JSON.parse(JSON.stringify(prevEntry.state.layers));
          state.canvas = JSON.parse(JSON.stringify(prevEntry.state.canvas));
          state.historyIndex = historyIndex - 1;
        });
      }
    },
    redo: () => {
      const { history, historyIndex } = get();
      if (historyIndex < history.length - 1) {
        const nextEntry = history[historyIndex + 1];
        set((state) => {
          state.layers = JSON.parse(JSON.stringify(nextEntry.state.layers));
          state.canvas = JSON.parse(JSON.stringify(nextEntry.state.canvas));
          state.historyIndex = historyIndex + 1;
        });
      }
    },
    // UI actions
    setSidebarTab: (tab) => {
      set((state) => {
        state.sidebarTab = tab;
      });
    },
    toggleGrid: () => {
      set((state) => {
        state.showGrid = !state.showGrid;
      });
    },
    toggleGuides: () => {
      set((state) => {
        state.showGuides = !state.showGuides;
      });
    },
    toggleSnap: () => {
      set((state) => {
        state.snapToGrid = !state.snapToGrid;
      });
    },
    // Brand Kit actions
    setBrandKit: (brandKit) => {
      set((state) => {
        state.brandKit = brandKit;
      });
    },
    // Project actions
    resetProject: () => {
      set((state) => {
        state.canvas = { ...initialCanvas };
        state.layers = [];
        state.selectedLayerIds = [];
        state.history = [];
        state.historyIndex = -1;
      });
    },
    loadProject: (canvas, layers) => {
      set((state) => {
        state.canvas = canvas;
        state.layers = layers;
        state.selectedLayerIds = [];
        state.history = [];
        state.historyIndex = -1;
      });
    },
  }))
);