A web app to help you design things, local, offline, on device. In your browser.
TypeScript
53.1%
JSON
45.6%
CSS
0.9%
Markdown
0.3%
JavaScript
0.1%
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;
});
},
}))
);
About
A web app to help you design things, local, offline, on device. In your browser.
0 stars
0 forks