mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add image editor type definitions and zustand store
Add complete type system for the editor (ToolType, CanvasObject, EditorLayer, EditorState, etc.) and the central Zustand store with zundo undo/redo middleware, 50-entry history limit, 500ms throttle, and O(1) _historyVersion equality checks.
This commit is contained in:
@@ -0,0 +1,724 @@
|
|||||||
|
// apps/web/src/stores/editor-store.ts
|
||||||
|
|
||||||
|
import { temporal } from "zundo";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { generateId } from "@/lib/utils";
|
||||||
|
import type {
|
||||||
|
AdjustmentValues,
|
||||||
|
CanvasObject,
|
||||||
|
EditorLayer,
|
||||||
|
EditorState,
|
||||||
|
FilterConfig,
|
||||||
|
ToolType,
|
||||||
|
} from "@/types/editor";
|
||||||
|
|
||||||
|
const DEFAULT_CANVAS_SIZE = { width: 1920, height: 1080 };
|
||||||
|
const DEFAULT_LAYER_ID = "layer-1";
|
||||||
|
const MAX_RECENT_COLORS = 12;
|
||||||
|
const MIN_ZOOM = 0.01;
|
||||||
|
const MAX_ZOOM = 64;
|
||||||
|
const MAX_BRUSH_SIZE = 500;
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
const DEFAULT_ADJUSTMENTS: AdjustmentValues = {
|
||||||
|
brightness: 0,
|
||||||
|
contrast: 0,
|
||||||
|
hue: 0,
|
||||||
|
saturation: 0,
|
||||||
|
luminance: 0,
|
||||||
|
exposure: 0,
|
||||||
|
vibrance: 0,
|
||||||
|
warmth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: FilterConfig[] = [
|
||||||
|
{ type: "blur", enabled: false, params: { radius: 0 } },
|
||||||
|
{ type: "sharpen", enabled: false, params: { amount: 0 } },
|
||||||
|
{ type: "noise", enabled: false, params: { amount: 0 } },
|
||||||
|
{ type: "pixelate", enabled: false, params: { size: 1 } },
|
||||||
|
{ type: "emboss", enabled: false, params: { strength: 0 } },
|
||||||
|
{ type: "grayscale", enabled: false, params: {} },
|
||||||
|
{ type: "sepia", enabled: false, params: {} },
|
||||||
|
{ type: "invert", enabled: false, params: {} },
|
||||||
|
{ type: "posterize", enabled: false, params: { levels: 8 } },
|
||||||
|
{ type: "solarize", enabled: false, params: {} },
|
||||||
|
{ type: "threshold", enabled: false, params: { level: 0.5 } },
|
||||||
|
{ type: "kaleidoscope", enabled: false, params: { power: 2, angle: 0 } },
|
||||||
|
{ type: "motionBlur", enabled: false, params: { angle: 0, distance: 10 } },
|
||||||
|
{
|
||||||
|
type: "radialBlur",
|
||||||
|
enabled: false,
|
||||||
|
params: { amount: 10, centerX: 0.5, centerY: 0.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "surfaceBlur",
|
||||||
|
enabled: false,
|
||||||
|
params: { radius: 5, threshold: 25 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "vignette",
|
||||||
|
enabled: false,
|
||||||
|
params: { amount: 50, midpoint: 50, roundness: 0, feather: 50 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "grain",
|
||||||
|
enabled: false,
|
||||||
|
params: { amount: 25, size: 25, roughness: 50 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function createDefaultLayer(id: string, name: string): EditorLayer {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
opacity: 1,
|
||||||
|
blendMode: "source-over",
|
||||||
|
thumbnail: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let layerCounter = 1;
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorState>()(
|
||||||
|
temporal(
|
||||||
|
(set, get) => ({
|
||||||
|
// --- Canvas ---
|
||||||
|
canvasSize: DEFAULT_CANVAS_SIZE,
|
||||||
|
zoom: 1,
|
||||||
|
panOffset: { x: 0, y: 0 },
|
||||||
|
cursorPosition: { x: 0, y: 0 },
|
||||||
|
|
||||||
|
// --- Image ---
|
||||||
|
sourceImageUrl: null,
|
||||||
|
sourceImageSize: null,
|
||||||
|
|
||||||
|
// --- Tool ---
|
||||||
|
activeTool: "move" as ToolType,
|
||||||
|
previousTool: null,
|
||||||
|
|
||||||
|
// --- Brush ---
|
||||||
|
brushSize: 10,
|
||||||
|
brushOpacity: 1,
|
||||||
|
brushHardness: 1,
|
||||||
|
|
||||||
|
// --- Colors ---
|
||||||
|
foregroundColor: "#000000",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
recentColors: [],
|
||||||
|
|
||||||
|
// --- Layers ---
|
||||||
|
layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")],
|
||||||
|
activeLayerId: DEFAULT_LAYER_ID,
|
||||||
|
|
||||||
|
// --- Objects ---
|
||||||
|
objects: [],
|
||||||
|
selectedObjectIds: [],
|
||||||
|
|
||||||
|
// --- Selection ---
|
||||||
|
selection: null,
|
||||||
|
|
||||||
|
// --- Crop ---
|
||||||
|
cropState: null,
|
||||||
|
isCropping: false,
|
||||||
|
|
||||||
|
// --- Adjustments ---
|
||||||
|
adjustments: { ...DEFAULT_ADJUSTMENTS },
|
||||||
|
filters: DEFAULT_FILTERS.map((f) => ({
|
||||||
|
...f,
|
||||||
|
params: { ...f.params },
|
||||||
|
})),
|
||||||
|
|
||||||
|
// --- Text ---
|
||||||
|
editingTextId: null,
|
||||||
|
|
||||||
|
// --- Shape settings ---
|
||||||
|
shapeFill: "#3b82f6",
|
||||||
|
shapeStroke: "#000000",
|
||||||
|
shapeStrokeWidth: 2,
|
||||||
|
shapeCornerRadius: 0,
|
||||||
|
shapePolygonSides: 6,
|
||||||
|
shapeStarPoints: 5,
|
||||||
|
|
||||||
|
// --- Clone stamp ---
|
||||||
|
cloneSource: null,
|
||||||
|
|
||||||
|
// --- Dodge/Burn/Sponge ---
|
||||||
|
dodgeBurnRange: "midtones",
|
||||||
|
dodgeBurnExposure: 50,
|
||||||
|
spongeMode: "saturate",
|
||||||
|
spongeFlow: 50,
|
||||||
|
|
||||||
|
// --- UI ---
|
||||||
|
rightPanelTab: "layers",
|
||||||
|
rightPanelVisible: true,
|
||||||
|
isSpaceHeld: false,
|
||||||
|
|
||||||
|
// --- Document ---
|
||||||
|
isDirty: false,
|
||||||
|
lastAutoSave: null,
|
||||||
|
|
||||||
|
// --- Clipboard ---
|
||||||
|
clipboard: null,
|
||||||
|
|
||||||
|
// --- Guides ---
|
||||||
|
guides: [],
|
||||||
|
snappingEnabled: true,
|
||||||
|
rulersVisible: false,
|
||||||
|
guidesVisible: true,
|
||||||
|
gridVisible: false,
|
||||||
|
|
||||||
|
// --- Loading ---
|
||||||
|
loadingState: null,
|
||||||
|
|
||||||
|
// --- History ---
|
||||||
|
lastAction: "Initial State",
|
||||||
|
_historyVersion: 0,
|
||||||
|
|
||||||
|
// ===== ACTIONS =====
|
||||||
|
|
||||||
|
setTool: (tool) => {
|
||||||
|
const { activeTool } = get();
|
||||||
|
set({
|
||||||
|
activeTool: tool,
|
||||||
|
previousTool: activeTool,
|
||||||
|
isCropping: tool === "crop",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setCursorPosition: (pos) => set({ cursorPosition: pos }),
|
||||||
|
|
||||||
|
setZoom: (zoom) => set({ zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)) }),
|
||||||
|
|
||||||
|
setPanOffset: (offset) => set({ panOffset: offset }),
|
||||||
|
|
||||||
|
loadImage: (url, width, height) => {
|
||||||
|
set({
|
||||||
|
sourceImageUrl: url,
|
||||||
|
sourceImageSize: { width, height },
|
||||||
|
canvasSize: { width, height },
|
||||||
|
zoom: 1,
|
||||||
|
panOffset: { x: 0, y: 0 },
|
||||||
|
lastAction: "Load Image",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resizeCanvas: (width, height, _anchor) => {
|
||||||
|
set({
|
||||||
|
canvasSize: { width, height },
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Resize Canvas",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resizeImage: (width, height) => {
|
||||||
|
set({
|
||||||
|
canvasSize: { width, height },
|
||||||
|
sourceImageSize: { width, height },
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Resize Image",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
rotateCanvas: (degrees) => {
|
||||||
|
const { canvasSize, objects } = get();
|
||||||
|
const newSize =
|
||||||
|
degrees === 180 ? canvasSize : { width: canvasSize.height, height: canvasSize.width };
|
||||||
|
set({
|
||||||
|
canvasSize: newSize,
|
||||||
|
sourceImageSize: newSize,
|
||||||
|
objects: objects.map((obj) => {
|
||||||
|
const attrs = { ...obj.attrs };
|
||||||
|
if ("x" in attrs && "y" in attrs) {
|
||||||
|
if (degrees === 90) {
|
||||||
|
const newX = canvasSize.height - (attrs as { y: number }).y;
|
||||||
|
const newY = (attrs as { x: number }).x;
|
||||||
|
(attrs as { x: number }).x = newX;
|
||||||
|
(attrs as { y: number }).y = newY;
|
||||||
|
} else if (degrees === 270) {
|
||||||
|
const newX = (attrs as { y: number }).y;
|
||||||
|
const newY = canvasSize.width - (attrs as { x: number }).x;
|
||||||
|
(attrs as { x: number }).x = newX;
|
||||||
|
(attrs as { y: number }).y = newY;
|
||||||
|
} else {
|
||||||
|
(attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x;
|
||||||
|
(attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...obj, attrs } as CanvasObject;
|
||||||
|
}),
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: `Rotate Canvas ${degrees}`,
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
flipCanvasHorizontal: () => {
|
||||||
|
const { canvasSize, objects } = get();
|
||||||
|
set({
|
||||||
|
objects: objects.map((obj) => {
|
||||||
|
const attrs = { ...obj.attrs };
|
||||||
|
if ("x" in attrs) {
|
||||||
|
(attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x;
|
||||||
|
}
|
||||||
|
return { ...obj, attrs } as CanvasObject;
|
||||||
|
}),
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Flip Horizontal",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
flipCanvasVertical: () => {
|
||||||
|
const { canvasSize, objects } = get();
|
||||||
|
set({
|
||||||
|
objects: objects.map((obj) => {
|
||||||
|
const attrs = { ...obj.attrs };
|
||||||
|
if ("y" in attrs) {
|
||||||
|
(attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y;
|
||||||
|
}
|
||||||
|
return { ...obj, attrs } as CanvasObject;
|
||||||
|
}),
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Flip Vertical",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
trimCanvas: () => {
|
||||||
|
set({
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Trim Canvas",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
setForegroundColor: (color) => {
|
||||||
|
const { recentColors } = get();
|
||||||
|
const updated = [color, ...recentColors.filter((c) => c !== color)].slice(
|
||||||
|
0,
|
||||||
|
MAX_RECENT_COLORS,
|
||||||
|
);
|
||||||
|
set({ foregroundColor: color, recentColors: updated });
|
||||||
|
},
|
||||||
|
|
||||||
|
setBackgroundColor: (color) => set({ backgroundColor: color }),
|
||||||
|
|
||||||
|
swapColors: () => {
|
||||||
|
const { foregroundColor, backgroundColor } = get();
|
||||||
|
set({
|
||||||
|
foregroundColor: backgroundColor,
|
||||||
|
backgroundColor: foregroundColor,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resetColors: () => set({ foregroundColor: "#000000", backgroundColor: "#ffffff" }),
|
||||||
|
|
||||||
|
// Objects
|
||||||
|
addObject: (obj) => {
|
||||||
|
set({
|
||||||
|
objects: [...get().objects, { ...obj, layerId: obj.layerId || get().activeLayerId }],
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: `Add ${obj.type.charAt(0).toUpperCase() + obj.type.slice(1)}`,
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateObject: (id, attrs) => {
|
||||||
|
set({
|
||||||
|
objects: get().objects.map((obj) =>
|
||||||
|
obj.id === id ? ({ ...obj, attrs: { ...obj.attrs, ...attrs } } as CanvasObject) : obj,
|
||||||
|
),
|
||||||
|
isDirty: true,
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeObjects: (ids) => {
|
||||||
|
const idSet = new Set(ids);
|
||||||
|
set({
|
||||||
|
objects: get().objects.filter((obj) => !idSet.has(obj.id)),
|
||||||
|
selectedObjectIds: get().selectedObjectIds.filter((id) => !idSet.has(id)),
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Delete",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedObjects: (ids) => set({ selectedObjectIds: ids }),
|
||||||
|
|
||||||
|
bringToFront: (objectId) => {
|
||||||
|
const { objects } = get();
|
||||||
|
const obj = objects.find((o) => o.id === objectId);
|
||||||
|
if (!obj) return;
|
||||||
|
const layerObjects = objects.filter((o) => o.layerId === obj.layerId);
|
||||||
|
const otherObjects = objects.filter((o) => o.layerId !== obj.layerId);
|
||||||
|
const reordered = [...layerObjects.filter((o) => o.id !== objectId), obj];
|
||||||
|
set({
|
||||||
|
objects: [...otherObjects, ...reordered],
|
||||||
|
lastAction: "Bring to Front",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
bringForward: (objectId) => {
|
||||||
|
const { objects } = get();
|
||||||
|
const idx = objects.findIndex((o) => o.id === objectId);
|
||||||
|
if (idx === -1 || idx === objects.length - 1) return;
|
||||||
|
const newObjects = [...objects];
|
||||||
|
[newObjects[idx], newObjects[idx + 1]] = [newObjects[idx + 1], newObjects[idx]];
|
||||||
|
set({
|
||||||
|
objects: newObjects,
|
||||||
|
lastAction: "Bring Forward",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
sendBackward: (objectId) => {
|
||||||
|
const { objects } = get();
|
||||||
|
const idx = objects.findIndex((o) => o.id === objectId);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
const newObjects = [...objects];
|
||||||
|
[newObjects[idx - 1], newObjects[idx]] = [newObjects[idx], newObjects[idx - 1]];
|
||||||
|
set({
|
||||||
|
objects: newObjects,
|
||||||
|
lastAction: "Send Backward",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
sendToBack: (objectId) => {
|
||||||
|
const { objects } = get();
|
||||||
|
const obj = objects.find((o) => o.id === objectId);
|
||||||
|
if (!obj) return;
|
||||||
|
const layerObjects = objects.filter((o) => o.layerId === obj.layerId);
|
||||||
|
const otherObjects = objects.filter((o) => o.layerId !== obj.layerId);
|
||||||
|
const reordered = [obj, ...layerObjects.filter((o) => o.id !== objectId)];
|
||||||
|
set({
|
||||||
|
objects: [...reordered, ...otherObjects],
|
||||||
|
lastAction: "Send to Back",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Layers
|
||||||
|
addLayer: () => {
|
||||||
|
layerCounter++;
|
||||||
|
const id = generateId();
|
||||||
|
const name = `Layer ${layerCounter}`;
|
||||||
|
const newLayer = createDefaultLayer(id, name);
|
||||||
|
const { layers, activeLayerId } = get();
|
||||||
|
const activeIndex = layers.findIndex((l) => l.id === activeLayerId);
|
||||||
|
const newLayers = [...layers];
|
||||||
|
newLayers.splice(activeIndex + 1, 0, newLayer);
|
||||||
|
set({
|
||||||
|
layers: newLayers,
|
||||||
|
activeLayerId: id,
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Add Layer",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeLayer: (id) => {
|
||||||
|
const { layers, objects, activeLayerId } = get();
|
||||||
|
if (layers.length <= 1) return;
|
||||||
|
const idx = layers.findIndex((l) => l.id === id);
|
||||||
|
const newLayers = layers.filter((l) => l.id !== id);
|
||||||
|
const newActiveId =
|
||||||
|
activeLayerId === id ? newLayers[Math.min(idx, newLayers.length - 1)].id : activeLayerId;
|
||||||
|
set({
|
||||||
|
layers: newLayers,
|
||||||
|
objects: objects.filter((o) => o.layerId !== id),
|
||||||
|
activeLayerId: newActiveId,
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Delete Layer",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
duplicateLayer: (id) => {
|
||||||
|
const { layers, objects } = get();
|
||||||
|
const source = layers.find((l) => l.id === id);
|
||||||
|
if (!source) return;
|
||||||
|
const newId = generateId();
|
||||||
|
layerCounter++;
|
||||||
|
const copy: EditorLayer = {
|
||||||
|
...source,
|
||||||
|
id: newId,
|
||||||
|
name: `${source.name} (copy)`,
|
||||||
|
thumbnail: null,
|
||||||
|
};
|
||||||
|
const sourceObjects = objects
|
||||||
|
.filter((o) => o.layerId === id)
|
||||||
|
.map((o) => ({ ...o, id: generateId(), layerId: newId }) as CanvasObject);
|
||||||
|
const idx = layers.findIndex((l) => l.id === id);
|
||||||
|
const newLayers = [...layers];
|
||||||
|
newLayers.splice(idx + 1, 0, copy);
|
||||||
|
set({
|
||||||
|
layers: newLayers,
|
||||||
|
objects: [...objects, ...sourceObjects],
|
||||||
|
activeLayerId: newId,
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Duplicate Layer",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveLayer: (id) => set({ activeLayerId: id }),
|
||||||
|
|
||||||
|
updateLayer: (id, updates) => {
|
||||||
|
set({
|
||||||
|
layers: get().layers.map((l) => (l.id === id ? { ...l, ...updates } : l)),
|
||||||
|
isDirty: true,
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderLayers: (fromIndex, toIndex) => {
|
||||||
|
const newLayers = [...get().layers];
|
||||||
|
const [moved] = newLayers.splice(fromIndex, 1);
|
||||||
|
newLayers.splice(toIndex, 0, moved);
|
||||||
|
set({
|
||||||
|
layers: newLayers,
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Reorder Layers",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
mergeDown: (id) => {
|
||||||
|
const { layers, objects } = get();
|
||||||
|
const idx = layers.findIndex((l) => l.id === id);
|
||||||
|
if (idx <= 0) return;
|
||||||
|
const belowLayer = layers[idx - 1];
|
||||||
|
const mergedObjects = objects.map((o) =>
|
||||||
|
o.layerId === id ? ({ ...o, layerId: belowLayer.id } as CanvasObject) : o,
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
layers: layers.filter((l) => l.id !== id),
|
||||||
|
objects: mergedObjects,
|
||||||
|
activeLayerId: belowLayer.id,
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Merge Down",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
flattenAll: () => {
|
||||||
|
const { layers, objects } = get();
|
||||||
|
const bottomLayer = layers[0];
|
||||||
|
set({
|
||||||
|
layers: [{ ...bottomLayer, name: "Flattened" }],
|
||||||
|
objects: objects.map((o) => ({ ...o, layerId: bottomLayer.id }) as CanvasObject),
|
||||||
|
activeLayerId: bottomLayer.id,
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Flatten All",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Adjustments
|
||||||
|
setAdjustment: (key, value) => {
|
||||||
|
const clamps: Record<string, [number, number]> = {
|
||||||
|
brightness: [-100, 100],
|
||||||
|
contrast: [-100, 100],
|
||||||
|
hue: [0, 359],
|
||||||
|
saturation: [-100, 100],
|
||||||
|
luminance: [-100, 100],
|
||||||
|
exposure: [-100, 100],
|
||||||
|
vibrance: [-100, 100],
|
||||||
|
warmth: [-100, 100],
|
||||||
|
};
|
||||||
|
const [min, max] = clamps[key] || [-100, 100];
|
||||||
|
set({
|
||||||
|
adjustments: {
|
||||||
|
...get().adjustments,
|
||||||
|
[key]: Math.max(min, Math.min(max, value)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resetAdjustments: () => set({ adjustments: { ...DEFAULT_ADJUSTMENTS } }),
|
||||||
|
|
||||||
|
toggleFilter: (type) => {
|
||||||
|
set({
|
||||||
|
filters: get().filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setFilterParam: (type, key, value) => {
|
||||||
|
set({
|
||||||
|
filters: get().filters.map((f) =>
|
||||||
|
f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
setSelection: (selection) => set({ selection }),
|
||||||
|
|
||||||
|
invertSelection: () => {
|
||||||
|
const { selection } = get();
|
||||||
|
if (!selection) return;
|
||||||
|
if (selection.mask) {
|
||||||
|
const inverted = new Uint8Array(selection.mask.length);
|
||||||
|
for (let i = 0; i < selection.mask.length; i++) {
|
||||||
|
inverted[i] = 255 - selection.mask[i];
|
||||||
|
}
|
||||||
|
set({ selection: { ...selection, mask: inverted } });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Crop
|
||||||
|
setCropState: (state) => set({ cropState: state, isCropping: state !== null }),
|
||||||
|
|
||||||
|
applyCrop: () => {
|
||||||
|
const { cropState, objects } = get();
|
||||||
|
if (!cropState) return;
|
||||||
|
set({
|
||||||
|
canvasSize: { width: cropState.width, height: cropState.height },
|
||||||
|
objects: objects.map((obj) => {
|
||||||
|
const attrs = { ...obj.attrs };
|
||||||
|
if ("x" in attrs) {
|
||||||
|
(attrs as { x: number }).x -= cropState.x;
|
||||||
|
}
|
||||||
|
if ("y" in attrs) {
|
||||||
|
(attrs as { y: number }).y -= cropState.y;
|
||||||
|
}
|
||||||
|
return { ...obj, attrs } as CanvasObject;
|
||||||
|
}),
|
||||||
|
cropState: null,
|
||||||
|
isCropping: false,
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Crop",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Brush
|
||||||
|
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(MAX_BRUSH_SIZE, size)) }),
|
||||||
|
setBrushOpacity: (opacity) => set({ brushOpacity: Math.max(0, Math.min(1, opacity)) }),
|
||||||
|
setBrushHardness: (hardness) => set({ brushHardness: Math.max(0, Math.min(1, hardness)) }),
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
copyObjects: () => {
|
||||||
|
const { objects, selectedObjectIds } = get();
|
||||||
|
const selected = objects.filter((o) => selectedObjectIds.includes(o.id));
|
||||||
|
set({ clipboard: selected });
|
||||||
|
},
|
||||||
|
|
||||||
|
cutObjects: () => {
|
||||||
|
const { objects, selectedObjectIds } = get();
|
||||||
|
const selected = objects.filter((o) => selectedObjectIds.includes(o.id));
|
||||||
|
set({ clipboard: selected });
|
||||||
|
get().removeObjects(selectedObjectIds);
|
||||||
|
},
|
||||||
|
|
||||||
|
pasteObjects: () => {
|
||||||
|
const { clipboard, activeLayerId } = get();
|
||||||
|
if (!clipboard || clipboard.length === 0) return;
|
||||||
|
const pasted = clipboard.map(
|
||||||
|
(obj) =>
|
||||||
|
({
|
||||||
|
...obj,
|
||||||
|
id: generateId(),
|
||||||
|
layerId: activeLayerId,
|
||||||
|
attrs: {
|
||||||
|
...obj.attrs,
|
||||||
|
...("x" in obj.attrs ? { x: (obj.attrs as { x: number }).x + 10 } : {}),
|
||||||
|
...("y" in obj.attrs ? { y: (obj.attrs as { y: number }).y + 10 } : {}),
|
||||||
|
},
|
||||||
|
}) as CanvasObject,
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
objects: [...get().objects, ...pasted],
|
||||||
|
selectedObjectIds: pasted.map((o) => o.id),
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Paste",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
pasteInPlace: () => {
|
||||||
|
const { clipboard, activeLayerId } = get();
|
||||||
|
if (!clipboard || clipboard.length === 0) return;
|
||||||
|
const pasted = clipboard.map(
|
||||||
|
(obj) =>
|
||||||
|
({
|
||||||
|
...obj,
|
||||||
|
id: generateId(),
|
||||||
|
layerId: activeLayerId,
|
||||||
|
}) as CanvasObject,
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
objects: [...get().objects, ...pasted],
|
||||||
|
selectedObjectIds: pasted.map((o) => o.id),
|
||||||
|
isDirty: true,
|
||||||
|
lastAction: "Paste in Place",
|
||||||
|
_historyVersion: get()._historyVersion + 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Guides
|
||||||
|
addGuide: (orientation, position) => {
|
||||||
|
set({
|
||||||
|
guides: [...get().guides, { id: generateId(), orientation, position }],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeGuide: (id) => {
|
||||||
|
set({ guides: get().guides.filter((g) => g.id !== id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateGuide: (id, position) => {
|
||||||
|
set({
|
||||||
|
guides: get().guides.map((g) => (g.id === id ? { ...g, position } : g)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSnapping: () => set({ snappingEnabled: !get().snappingEnabled }),
|
||||||
|
toggleRulers: () => set({ rulersVisible: !get().rulersVisible }),
|
||||||
|
toggleGuides: () => set({ guidesVisible: !get().guidesVisible }),
|
||||||
|
toggleGrid: () => set({ gridVisible: !get().gridVisible }),
|
||||||
|
|
||||||
|
// Document
|
||||||
|
markDirty: () => set({ isDirty: true }),
|
||||||
|
markClean: () => set({ isDirty: false }),
|
||||||
|
setLoadingState: (state) => set({ loadingState: state }),
|
||||||
|
|
||||||
|
// Right panel
|
||||||
|
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
|
||||||
|
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
partialize: (state) => ({
|
||||||
|
layers: state.layers,
|
||||||
|
objects: state.objects,
|
||||||
|
canvasSize: state.canvasSize,
|
||||||
|
adjustments: state.adjustments,
|
||||||
|
filters: state.filters,
|
||||||
|
guides: state.guides,
|
||||||
|
sourceImageUrl: state.sourceImageUrl,
|
||||||
|
sourceImageSize: state.sourceImageSize,
|
||||||
|
_historyVersion: state._historyVersion,
|
||||||
|
}),
|
||||||
|
limit: MAX_HISTORY,
|
||||||
|
equality: (a, b) =>
|
||||||
|
(a as { _historyVersion: number })._historyVersion ===
|
||||||
|
(b as { _historyVersion: number })._historyVersion,
|
||||||
|
handleSet: (handleSet) => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
return (state) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => handleSet(state), 500);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
// apps/web/src/types/editor.ts
|
||||||
|
|
||||||
|
export type ToolType =
|
||||||
|
| "move"
|
||||||
|
| "marquee-rect"
|
||||||
|
| "marquee-ellipse"
|
||||||
|
| "lasso-free"
|
||||||
|
| "lasso-poly"
|
||||||
|
| "magic-wand"
|
||||||
|
| "crop"
|
||||||
|
| "eyedropper"
|
||||||
|
| "brush"
|
||||||
|
| "eraser"
|
||||||
|
| "pencil"
|
||||||
|
| "clone-stamp"
|
||||||
|
| "dodge"
|
||||||
|
| "burn"
|
||||||
|
| "sponge"
|
||||||
|
| "blur-brush"
|
||||||
|
| "sharpen-brush"
|
||||||
|
| "smudge"
|
||||||
|
| "fill"
|
||||||
|
| "gradient"
|
||||||
|
| "shape-rect"
|
||||||
|
| "shape-ellipse"
|
||||||
|
| "shape-line"
|
||||||
|
| "shape-arrow"
|
||||||
|
| "shape-polygon"
|
||||||
|
| "shape-star"
|
||||||
|
| "text"
|
||||||
|
| "hand"
|
||||||
|
| "zoom"
|
||||||
|
| "transform";
|
||||||
|
|
||||||
|
export interface LineAttrs {
|
||||||
|
points: number[];
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
tension: number;
|
||||||
|
lineCap: "butt" | "round" | "square";
|
||||||
|
lineJoin: "bevel" | "round" | "miter";
|
||||||
|
opacity: number;
|
||||||
|
globalCompositeOperation: string;
|
||||||
|
shadowBlur?: number;
|
||||||
|
shadowColor?: string;
|
||||||
|
shadowOffsetX?: number;
|
||||||
|
shadowOffsetY?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RectAttrs {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
cornerRadius: number;
|
||||||
|
rotation: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EllipseAttrs {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radiusX: number;
|
||||||
|
radiusY: number;
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
rotation: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextAttrs {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
text: string;
|
||||||
|
fontFamily: string;
|
||||||
|
fontSize: number;
|
||||||
|
fontStyle: string;
|
||||||
|
fontVariant: string;
|
||||||
|
textDecoration: string;
|
||||||
|
align: "left" | "center" | "right";
|
||||||
|
fill: string;
|
||||||
|
lineHeight: number;
|
||||||
|
letterSpacing: number;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
wrap?: "word" | "char" | "none";
|
||||||
|
rotation: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageAttrs {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
rotation: number;
|
||||||
|
opacity: number;
|
||||||
|
src: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArrowAttrs {
|
||||||
|
points: number[];
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
pointerLength: number;
|
||||||
|
pointerWidth: number;
|
||||||
|
rotation: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolygonAttrs {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
sides: number;
|
||||||
|
radius: number;
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
rotation: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StarAttrs {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
numPoints: number;
|
||||||
|
innerRadius: number;
|
||||||
|
outerRadius: number;
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
rotation: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CanvasObject =
|
||||||
|
| { id: string; type: "line"; layerId: string; attrs: LineAttrs; effects?: ObjectEffects }
|
||||||
|
| { id: string; type: "rect"; layerId: string; attrs: RectAttrs; effects?: ObjectEffects }
|
||||||
|
| { id: string; type: "ellipse"; layerId: string; attrs: EllipseAttrs; effects?: ObjectEffects }
|
||||||
|
| { id: string; type: "text"; layerId: string; attrs: TextAttrs; effects?: ObjectEffects }
|
||||||
|
| { id: string; type: "image"; layerId: string; attrs: ImageAttrs; effects?: ObjectEffects }
|
||||||
|
| { id: string; type: "arrow"; layerId: string; attrs: ArrowAttrs; effects?: ObjectEffects }
|
||||||
|
| { id: string; type: "polygon"; layerId: string; attrs: PolygonAttrs; effects?: ObjectEffects }
|
||||||
|
| { id: string; type: "star"; layerId: string; attrs: StarAttrs; effects?: ObjectEffects };
|
||||||
|
|
||||||
|
export interface ObjectEffects {
|
||||||
|
dropShadow?: {
|
||||||
|
enabled: boolean;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
angle: number;
|
||||||
|
distance: number;
|
||||||
|
blur: number;
|
||||||
|
spread: number;
|
||||||
|
};
|
||||||
|
innerShadow?: {
|
||||||
|
enabled: boolean;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
angle: number;
|
||||||
|
distance: number;
|
||||||
|
blur: number;
|
||||||
|
};
|
||||||
|
outerGlow?: {
|
||||||
|
enabled: boolean;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
blur: number;
|
||||||
|
spread: number;
|
||||||
|
};
|
||||||
|
stroke?: {
|
||||||
|
enabled: boolean;
|
||||||
|
color: string;
|
||||||
|
width: number;
|
||||||
|
position: "inside" | "center" | "outside";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorLayer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
visible: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
opacity: number;
|
||||||
|
blendMode: string;
|
||||||
|
thumbnail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionState {
|
||||||
|
type: "rect" | "ellipse" | "lasso" | "wand";
|
||||||
|
points: number[];
|
||||||
|
bounds: { x: number; y: number; width: number; height: number };
|
||||||
|
mask?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CropState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
aspectRatio: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdjustmentValues {
|
||||||
|
brightness: number;
|
||||||
|
contrast: number;
|
||||||
|
hue: number;
|
||||||
|
saturation: number;
|
||||||
|
luminance: number;
|
||||||
|
exposure: number;
|
||||||
|
vibrance: number;
|
||||||
|
warmth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterConfig {
|
||||||
|
type: string;
|
||||||
|
enabled: boolean;
|
||||||
|
params: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Guide {
|
||||||
|
id: string;
|
||||||
|
orientation: "horizontal" | "vertical";
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnchorPosition =
|
||||||
|
| "top-left"
|
||||||
|
| "top-center"
|
||||||
|
| "top-right"
|
||||||
|
| "center-left"
|
||||||
|
| "center"
|
||||||
|
| "center-right"
|
||||||
|
| "bottom-left"
|
||||||
|
| "bottom-center"
|
||||||
|
| "bottom-right";
|
||||||
|
|
||||||
|
export interface LoadingState {
|
||||||
|
operation: string;
|
||||||
|
progress: number | null;
|
||||||
|
cancellable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloneSource {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
aligned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorState {
|
||||||
|
// Canvas
|
||||||
|
canvasSize: { width: number; height: number };
|
||||||
|
zoom: number;
|
||||||
|
panOffset: { x: number; y: number };
|
||||||
|
cursorPosition: { x: number; y: number };
|
||||||
|
|
||||||
|
// Image
|
||||||
|
sourceImageUrl: string | null;
|
||||||
|
sourceImageSize: { width: number; height: number } | null;
|
||||||
|
|
||||||
|
// Active tool
|
||||||
|
activeTool: ToolType;
|
||||||
|
previousTool: ToolType | null;
|
||||||
|
|
||||||
|
// Brush/Eraser settings
|
||||||
|
brushSize: number;
|
||||||
|
brushOpacity: number;
|
||||||
|
brushHardness: number;
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
foregroundColor: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
recentColors: string[];
|
||||||
|
|
||||||
|
// Layers
|
||||||
|
layers: EditorLayer[];
|
||||||
|
activeLayerId: string;
|
||||||
|
|
||||||
|
// Objects
|
||||||
|
objects: CanvasObject[];
|
||||||
|
selectedObjectIds: string[];
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
selection: SelectionState | null;
|
||||||
|
|
||||||
|
// Crop
|
||||||
|
cropState: CropState | null;
|
||||||
|
isCropping: boolean;
|
||||||
|
|
||||||
|
// Adjustments & Filters
|
||||||
|
adjustments: AdjustmentValues;
|
||||||
|
filters: FilterConfig[];
|
||||||
|
|
||||||
|
// Text
|
||||||
|
editingTextId: string | null;
|
||||||
|
|
||||||
|
// Shape settings
|
||||||
|
shapeFill: string;
|
||||||
|
shapeStroke: string;
|
||||||
|
shapeStrokeWidth: number;
|
||||||
|
shapeCornerRadius: number;
|
||||||
|
shapePolygonSides: number;
|
||||||
|
shapeStarPoints: number;
|
||||||
|
|
||||||
|
// Clone stamp
|
||||||
|
cloneSource: CloneSource | null;
|
||||||
|
|
||||||
|
// Dodge/Burn/Sponge
|
||||||
|
dodgeBurnRange: "shadows" | "midtones" | "highlights";
|
||||||
|
dodgeBurnExposure: number;
|
||||||
|
spongeMode: "saturate" | "desaturate";
|
||||||
|
spongeFlow: number;
|
||||||
|
|
||||||
|
// UI
|
||||||
|
rightPanelTab: "layers" | "adjustments" | "history";
|
||||||
|
rightPanelVisible: boolean;
|
||||||
|
isSpaceHeld: boolean;
|
||||||
|
|
||||||
|
// Document state
|
||||||
|
isDirty: boolean;
|
||||||
|
lastAutoSave: number | null;
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
clipboard: CanvasObject[] | null;
|
||||||
|
|
||||||
|
// Guides
|
||||||
|
guides: Guide[];
|
||||||
|
snappingEnabled: boolean;
|
||||||
|
rulersVisible: boolean;
|
||||||
|
guidesVisible: boolean;
|
||||||
|
gridVisible: boolean;
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
loadingState: LoadingState | null;
|
||||||
|
|
||||||
|
// History tracking
|
||||||
|
lastAction: string;
|
||||||
|
_historyVersion: number;
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
// Tool
|
||||||
|
setTool: (tool: ToolType) => void;
|
||||||
|
setCursorPosition: (pos: { x: number; y: number }) => void;
|
||||||
|
|
||||||
|
// Canvas
|
||||||
|
setZoom: (zoom: number) => void;
|
||||||
|
setPanOffset: (offset: { x: number; y: number }) => void;
|
||||||
|
loadImage: (url: string, width: number, height: number) => void;
|
||||||
|
resizeCanvas: (width: number, height: number, anchor: AnchorPosition) => void;
|
||||||
|
resizeImage: (width: number, height: number) => void;
|
||||||
|
rotateCanvas: (degrees: 90 | 180 | 270) => void;
|
||||||
|
flipCanvasHorizontal: () => void;
|
||||||
|
flipCanvasVertical: () => void;
|
||||||
|
trimCanvas: () => void;
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
setForegroundColor: (color: string) => void;
|
||||||
|
setBackgroundColor: (color: string) => void;
|
||||||
|
swapColors: () => void;
|
||||||
|
resetColors: () => void;
|
||||||
|
|
||||||
|
// Objects
|
||||||
|
addObject: (obj: CanvasObject) => void;
|
||||||
|
updateObject: (id: string, attrs: Partial<CanvasObject["attrs"]>) => void;
|
||||||
|
removeObjects: (ids: string[]) => void;
|
||||||
|
setSelectedObjects: (ids: string[]) => void;
|
||||||
|
bringToFront: (objectId: string) => void;
|
||||||
|
bringForward: (objectId: string) => void;
|
||||||
|
sendBackward: (objectId: string) => void;
|
||||||
|
sendToBack: (objectId: string) => void;
|
||||||
|
|
||||||
|
// Layers
|
||||||
|
addLayer: () => void;
|
||||||
|
removeLayer: (id: string) => void;
|
||||||
|
duplicateLayer: (id: string) => void;
|
||||||
|
setActiveLayer: (id: string) => void;
|
||||||
|
updateLayer: (id: string, updates: Partial<EditorLayer>) => void;
|
||||||
|
reorderLayers: (fromIndex: number, toIndex: number) => void;
|
||||||
|
mergeDown: (id: string) => void;
|
||||||
|
flattenAll: () => void;
|
||||||
|
|
||||||
|
// Adjustments & Filters
|
||||||
|
setAdjustment: (key: keyof AdjustmentValues, value: number) => void;
|
||||||
|
resetAdjustments: () => void;
|
||||||
|
toggleFilter: (type: string) => void;
|
||||||
|
setFilterParam: (type: string, key: string, value: number) => void;
|
||||||
|
|
||||||
|
// Selection
|
||||||
|
setSelection: (selection: SelectionState | null) => void;
|
||||||
|
invertSelection: () => void;
|
||||||
|
|
||||||
|
// Crop
|
||||||
|
setCropState: (state: CropState | null) => void;
|
||||||
|
applyCrop: () => void;
|
||||||
|
|
||||||
|
// Brush
|
||||||
|
setBrushSize: (size: number) => void;
|
||||||
|
setBrushOpacity: (opacity: number) => void;
|
||||||
|
setBrushHardness: (hardness: number) => void;
|
||||||
|
|
||||||
|
// Clipboard
|
||||||
|
copyObjects: () => void;
|
||||||
|
cutObjects: () => void;
|
||||||
|
pasteObjects: () => void;
|
||||||
|
pasteInPlace: () => void;
|
||||||
|
|
||||||
|
// Guides
|
||||||
|
addGuide: (orientation: "horizontal" | "vertical", position: number) => void;
|
||||||
|
removeGuide: (id: string) => void;
|
||||||
|
updateGuide: (id: string, position: number) => void;
|
||||||
|
toggleSnapping: () => void;
|
||||||
|
toggleRulers: () => void;
|
||||||
|
toggleGuides: () => void;
|
||||||
|
toggleGrid: () => void;
|
||||||
|
|
||||||
|
// Document state
|
||||||
|
markDirty: () => void;
|
||||||
|
markClean: () => void;
|
||||||
|
setLoadingState: (state: LoadingState | null) => void;
|
||||||
|
|
||||||
|
// Right panel
|
||||||
|
setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void;
|
||||||
|
toggleRightPanel: () => void;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user