2026-05-06 23:05:33 +08:00
|
|
|
// 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,
|
2026-05-07 23:47:08 +08:00
|
|
|
SelectionMode,
|
2026-05-06 23:05:33 +08:00
|
|
|
ToolType,
|
|
|
|
|
} from "@/types/editor";
|
|
|
|
|
|
2026-05-08 16:43:27 +08:00
|
|
|
// Helpers for objects that use points arrays (line, arrow) vs positioned objects
|
|
|
|
|
function hasPointsArray(obj: CanvasObject): obj is CanvasObject & { attrs: { points: number[] } } {
|
|
|
|
|
return "points" in obj.attrs && Array.isArray((obj.attrs as { points: number[] }).points);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isCenterBased(obj: CanvasObject): boolean {
|
|
|
|
|
return obj.type === "ellipse" || obj.type === "polygon" || obj.type === "star";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Extended store state with additional fields/methods not yet in the shared interface
|
|
|
|
|
interface EditorStateExtensions {
|
|
|
|
|
canvasBackground: string;
|
|
|
|
|
commitHistory: (action: string) => void;
|
|
|
|
|
batchNudge: (objectIds: string[], dx: number, dy: number) => void;
|
|
|
|
|
updateLayerThumbnail: (layerId: string, thumbnailDataUrl: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 23:05:33 +08:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:47:08 +08:00
|
|
|
function nextLayerNumber(layers: EditorLayer[]): number {
|
|
|
|
|
let max = 0;
|
|
|
|
|
for (const l of layers) {
|
|
|
|
|
const m = l.name.match(/^Layer (\d+)/);
|
|
|
|
|
if (m) max = Math.max(max, Number(m[1]));
|
|
|
|
|
}
|
|
|
|
|
return max + 1;
|
|
|
|
|
}
|
2026-05-06 23:05:33 +08:00
|
|
|
|
2026-05-08 16:43:27 +08:00
|
|
|
export const useEditorStore = create<EditorState & EditorStateExtensions>()(
|
2026-05-06 23:05:33 +08:00
|
|
|
temporal(
|
|
|
|
|
(set, get) => ({
|
|
|
|
|
// --- Canvas ---
|
|
|
|
|
canvasSize: DEFAULT_CANVAS_SIZE,
|
|
|
|
|
zoom: 1,
|
|
|
|
|
panOffset: { x: 0, y: 0 },
|
|
|
|
|
cursorPosition: { x: 0, y: 0 },
|
|
|
|
|
|
2026-05-08 16:43:27 +08:00
|
|
|
// --- Canvas background (used when canvas is resized larger) ---
|
|
|
|
|
canvasBackground: "#ffffff",
|
|
|
|
|
|
2026-05-06 23:05:33 +08:00
|
|
|
// --- 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,
|
2026-05-07 23:47:08 +08:00
|
|
|
selectionMode: "new" as SelectionMode,
|
|
|
|
|
magicWandTolerance: 32,
|
2026-05-09 00:14:57 +08:00
|
|
|
magicWandContiguous: true,
|
2026-05-06 23:05:33 +08:00
|
|
|
|
|
|
|
|
// --- 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,
|
2026-05-07 23:47:08 +08:00
|
|
|
cloneAligned: true,
|
2026-05-06 23:05:33 +08:00
|
|
|
|
|
|
|
|
// --- Dodge/Burn/Sponge ---
|
|
|
|
|
dodgeBurnRange: "midtones",
|
|
|
|
|
dodgeBurnExposure: 50,
|
|
|
|
|
spongeMode: "saturate",
|
|
|
|
|
spongeFlow: 50,
|
|
|
|
|
|
2026-05-07 23:47:08 +08:00
|
|
|
// --- Fill tool ---
|
|
|
|
|
fillTolerance: 32,
|
|
|
|
|
fillContiguous: true,
|
|
|
|
|
|
|
|
|
|
// --- Gradient tool ---
|
|
|
|
|
gradientType: "linear",
|
|
|
|
|
gradientOpacity: 1,
|
|
|
|
|
gradientReverse: false,
|
|
|
|
|
|
|
|
|
|
// --- Pixel brush ---
|
|
|
|
|
pixelBrushStrength: 50,
|
|
|
|
|
|
2026-05-06 23:05:33 +08:00
|
|
|
// --- 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) => {
|
2026-05-07 23:58:29 +08:00
|
|
|
const { activeTool, canvasSize, cropState } = get();
|
|
|
|
|
const leavingCrop = activeTool === "crop" && tool !== "crop";
|
|
|
|
|
const enteringCrop = tool === "crop" && activeTool !== "crop";
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
|
|
|
|
activeTool: tool,
|
|
|
|
|
previousTool: activeTool,
|
|
|
|
|
isCropping: tool === "crop",
|
2026-05-07 23:58:29 +08:00
|
|
|
...(leavingCrop ? { cropState: null } : {}),
|
|
|
|
|
...(enteringCrop && !cropState
|
|
|
|
|
? {
|
|
|
|
|
cropState: {
|
|
|
|
|
x: canvasSize.width * 0.1,
|
|
|
|
|
y: canvasSize.height * 0.1,
|
|
|
|
|
width: canvasSize.width * 0.8,
|
|
|
|
|
height: canvasSize.height * 0.8,
|
|
|
|
|
aspectRatio: null,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
2026-05-06 23:05:33 +08:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-05-07 23:47:08 +08:00
|
|
|
const oldUrl = get().sourceImageUrl;
|
|
|
|
|
if (oldUrl?.startsWith("blob:")) {
|
|
|
|
|
URL.revokeObjectURL(oldUrl);
|
|
|
|
|
}
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
|
|
|
|
sourceImageUrl: url,
|
|
|
|
|
sourceImageSize: { width, height },
|
|
|
|
|
canvasSize: { width, height },
|
|
|
|
|
zoom: 1,
|
|
|
|
|
panOffset: { x: 0, y: 0 },
|
2026-05-07 23:47:08 +08:00
|
|
|
objects: [],
|
|
|
|
|
selectedObjectIds: [],
|
|
|
|
|
selection: null,
|
|
|
|
|
cropState: null,
|
|
|
|
|
isCropping: false,
|
|
|
|
|
adjustments: { ...DEFAULT_ADJUSTMENTS },
|
|
|
|
|
filters: DEFAULT_FILTERS.map((f) => ({
|
|
|
|
|
...f,
|
|
|
|
|
params: { ...f.params },
|
|
|
|
|
})),
|
|
|
|
|
clipboard: null,
|
|
|
|
|
editingTextId: null,
|
|
|
|
|
layers: [createDefaultLayer(DEFAULT_LAYER_ID, "Layer 1")],
|
|
|
|
|
activeLayerId: DEFAULT_LAYER_ID,
|
2026-05-06 23:05:33 +08:00
|
|
|
lastAction: "Load Image",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-08 16:43:27 +08:00
|
|
|
resizeCanvas: (width, height, anchor, fill) => {
|
2026-05-07 23:47:08 +08:00
|
|
|
const { canvasSize, objects } = get();
|
|
|
|
|
const dw = width - canvasSize.width;
|
|
|
|
|
const dh = height - canvasSize.height;
|
|
|
|
|
let offsetX = 0;
|
|
|
|
|
let offsetY = 0;
|
|
|
|
|
if (anchor === "center") {
|
|
|
|
|
offsetX = dw / 2;
|
|
|
|
|
offsetY = dh / 2;
|
|
|
|
|
} else {
|
|
|
|
|
if (anchor.includes("center")) {
|
|
|
|
|
offsetX = dw / 2;
|
|
|
|
|
} else if (anchor.includes("right")) {
|
|
|
|
|
offsetX = dw;
|
|
|
|
|
}
|
|
|
|
|
if (anchor.startsWith("center")) {
|
|
|
|
|
offsetY = dh / 2;
|
|
|
|
|
} else if (anchor.startsWith("bottom")) {
|
|
|
|
|
offsetY = dh;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
|
|
|
|
canvasSize: { width, height },
|
2026-05-08 16:43:27 +08:00
|
|
|
...(fill ? { canvasBackground: fill } : {}),
|
2026-05-07 23:47:08 +08:00
|
|
|
objects:
|
|
|
|
|
offsetX !== 0 || offsetY !== 0
|
|
|
|
|
? objects.map((obj) => {
|
|
|
|
|
const attrs = { ...obj.attrs };
|
2026-05-08 16:43:27 +08:00
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = [...(attrs as { points: number[] }).points];
|
|
|
|
|
for (let i = 0; i < pts.length; i += 2) {
|
|
|
|
|
pts[i] += offsetX;
|
|
|
|
|
pts[i + 1] += offsetY;
|
|
|
|
|
}
|
|
|
|
|
(attrs as { points: number[] }).points = pts;
|
|
|
|
|
} else {
|
|
|
|
|
if ("x" in attrs) {
|
|
|
|
|
(attrs as { x: number }).x += offsetX;
|
|
|
|
|
}
|
|
|
|
|
if ("y" in attrs) {
|
|
|
|
|
(attrs as { y: number }).y += offsetY;
|
|
|
|
|
}
|
2026-05-07 23:47:08 +08:00
|
|
|
}
|
|
|
|
|
return { ...obj, attrs } as CanvasObject;
|
|
|
|
|
})
|
|
|
|
|
: objects,
|
2026-05-06 23:05:33 +08:00
|
|
|
isDirty: true,
|
|
|
|
|
lastAction: "Resize Canvas",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-08 16:43:27 +08:00
|
|
|
resizeImage: (width, height, resample) => {
|
|
|
|
|
void resample; // accepted for future server-side resize; client-side scales objects only
|
|
|
|
|
const { canvasSize, objects } = get();
|
|
|
|
|
const scaleX = width / canvasSize.width;
|
|
|
|
|
const scaleY = height / canvasSize.height;
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
|
|
|
|
canvasSize: { width, height },
|
|
|
|
|
sourceImageSize: { width, height },
|
2026-05-08 16:43:27 +08:00
|
|
|
objects: objects.map((obj) => {
|
|
|
|
|
const attrs = { ...obj.attrs };
|
|
|
|
|
const a = attrs as unknown as Record<string, number>;
|
|
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = [...(attrs as { points: number[] }).points];
|
|
|
|
|
for (let i = 0; i < pts.length; i += 2) {
|
|
|
|
|
pts[i] *= scaleX;
|
|
|
|
|
pts[i + 1] *= scaleY;
|
|
|
|
|
}
|
|
|
|
|
(attrs as { points: number[] }).points = pts;
|
|
|
|
|
if ("strokeWidth" in attrs) a.strokeWidth *= Math.min(scaleX, scaleY);
|
|
|
|
|
} else {
|
|
|
|
|
if ("x" in attrs) a.x *= scaleX;
|
|
|
|
|
if ("y" in attrs) a.y *= scaleY;
|
|
|
|
|
if ("width" in attrs) a.width *= scaleX;
|
|
|
|
|
if ("height" in attrs) a.height *= scaleY;
|
|
|
|
|
if ("radius" in attrs) a.radius *= Math.min(scaleX, scaleY);
|
|
|
|
|
if ("radiusX" in attrs) a.radiusX *= scaleX;
|
|
|
|
|
if ("radiusY" in attrs) a.radiusY *= scaleY;
|
|
|
|
|
if ("innerRadius" in attrs) a.innerRadius *= Math.min(scaleX, scaleY);
|
|
|
|
|
if ("outerRadius" in attrs) a.outerRadius *= Math.min(scaleX, scaleY);
|
|
|
|
|
if ("fontSize" in attrs) a.fontSize *= Math.min(scaleX, scaleY);
|
|
|
|
|
if ("strokeWidth" in attrs) a.strokeWidth *= Math.min(scaleX, scaleY);
|
|
|
|
|
}
|
|
|
|
|
return { ...obj, attrs } as CanvasObject;
|
|
|
|
|
}),
|
2026-05-06 23:05:33 +08:00
|
|
|
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 };
|
2026-05-07 23:47:08 +08:00
|
|
|
const a = attrs as unknown as Record<string, number>;
|
2026-05-08 16:43:27 +08:00
|
|
|
|
|
|
|
|
// Handle points-based objects (line, arrow)
|
|
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = [...(attrs as { points: number[] }).points];
|
|
|
|
|
for (let i = 0; i < pts.length; i += 2) {
|
|
|
|
|
const px = pts[i];
|
|
|
|
|
const py = pts[i + 1];
|
|
|
|
|
if (degrees === 90) {
|
|
|
|
|
pts[i] = canvasSize.height - py;
|
|
|
|
|
pts[i + 1] = px;
|
|
|
|
|
} else if (degrees === 270) {
|
|
|
|
|
pts[i] = py;
|
|
|
|
|
pts[i + 1] = canvasSize.width - px;
|
|
|
|
|
} else {
|
|
|
|
|
pts[i] = canvasSize.width - px;
|
|
|
|
|
pts[i + 1] = canvasSize.height - py;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(attrs as { points: number[] }).points = pts;
|
|
|
|
|
} else {
|
|
|
|
|
const centerBased = isCenterBased(obj);
|
|
|
|
|
const hasPos = "x" in attrs && "y" in attrs;
|
|
|
|
|
const hasSize = "width" in attrs && "height" in attrs;
|
|
|
|
|
|
|
|
|
|
if (hasPos) {
|
|
|
|
|
if (degrees === 90) {
|
|
|
|
|
const newX = canvasSize.height - a.y - (centerBased ? 0 : hasSize ? a.height : 0);
|
|
|
|
|
const newY = a.x;
|
|
|
|
|
a.x = newX;
|
|
|
|
|
a.y = newY;
|
|
|
|
|
} else if (degrees === 270) {
|
|
|
|
|
const newX = a.y;
|
|
|
|
|
const newY = canvasSize.width - a.x - (centerBased ? 0 : hasSize ? a.width : 0);
|
|
|
|
|
a.x = newX;
|
|
|
|
|
a.y = newY;
|
|
|
|
|
} else {
|
|
|
|
|
a.x = canvasSize.width - a.x - (centerBased ? 0 : hasSize ? a.width : 0);
|
|
|
|
|
a.y = canvasSize.height - a.y - (centerBased ? 0 : hasSize ? a.height : 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (hasSize && degrees !== 180) {
|
|
|
|
|
const oldW = a.width;
|
|
|
|
|
a.width = a.height;
|
|
|
|
|
a.height = oldW;
|
|
|
|
|
}
|
|
|
|
|
if ("radiusX" in attrs && "radiusY" in attrs && degrees !== 180) {
|
|
|
|
|
const oldRx = a.radiusX;
|
|
|
|
|
a.radiusX = a.radiusY;
|
|
|
|
|
a.radiusY = oldRx;
|
2026-05-06 23:05:33 +08:00
|
|
|
}
|
2026-05-07 23:47:08 +08:00
|
|
|
}
|
2026-05-08 20:27:06 +08:00
|
|
|
if ("rotation" in attrs) {
|
|
|
|
|
a.rotation = ((a.rotation || 0) + degrees) % 360;
|
|
|
|
|
}
|
2026-05-06 23:05:33 +08:00
|
|
|
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 };
|
2026-05-08 16:43:27 +08:00
|
|
|
const a = attrs as unknown as Record<string, number>;
|
|
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = [...(attrs as { points: number[] }).points];
|
|
|
|
|
for (let i = 0; i < pts.length; i += 2) {
|
|
|
|
|
pts[i] = canvasSize.width - pts[i];
|
|
|
|
|
}
|
|
|
|
|
(attrs as { points: number[] }).points = pts;
|
|
|
|
|
} else if ("x" in attrs) {
|
|
|
|
|
const centerBased = isCenterBased(obj);
|
|
|
|
|
const w = centerBased ? 0 : "width" in attrs ? a.width : 0;
|
2026-05-07 23:47:08 +08:00
|
|
|
a.x = canvasSize.width - a.x - w;
|
2026-05-06 23:05:33 +08:00
|
|
|
}
|
2026-05-08 20:27:06 +08:00
|
|
|
if ("rotation" in attrs) {
|
|
|
|
|
a.rotation = (360 - (a.rotation || 0)) % 360;
|
|
|
|
|
}
|
2026-05-06 23:05:33 +08:00
|
|
|
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 };
|
2026-05-08 16:43:27 +08:00
|
|
|
const a = attrs as unknown as Record<string, number>;
|
|
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = [...(attrs as { points: number[] }).points];
|
|
|
|
|
for (let i = 1; i < pts.length; i += 2) {
|
|
|
|
|
pts[i] = canvasSize.height - pts[i];
|
|
|
|
|
}
|
|
|
|
|
(attrs as { points: number[] }).points = pts;
|
|
|
|
|
} else if ("y" in attrs) {
|
|
|
|
|
const centerBased = isCenterBased(obj);
|
|
|
|
|
const h = centerBased ? 0 : "height" in attrs ? a.height : 0;
|
2026-05-07 23:47:08 +08:00
|
|
|
a.y = canvasSize.height - a.y - h;
|
2026-05-06 23:05:33 +08:00
|
|
|
}
|
2026-05-08 20:27:06 +08:00
|
|
|
if ("rotation" in attrs) {
|
|
|
|
|
a.rotation = (360 - (a.rotation || 0)) % 360;
|
|
|
|
|
}
|
2026-05-06 23:05:33 +08:00
|
|
|
return { ...obj, attrs } as CanvasObject;
|
|
|
|
|
}),
|
|
|
|
|
isDirty: true,
|
|
|
|
|
lastAction: "Flip Vertical",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
trimCanvas: () => {
|
2026-05-07 23:47:08 +08:00
|
|
|
const { objects, canvasSize } = get();
|
|
|
|
|
if (objects.length === 0) return;
|
|
|
|
|
let minX = canvasSize.width;
|
|
|
|
|
let minY = canvasSize.height;
|
|
|
|
|
let maxX = 0;
|
|
|
|
|
let maxY = 0;
|
|
|
|
|
for (const obj of objects) {
|
|
|
|
|
const a = obj.attrs as unknown as Record<string, number>;
|
2026-05-08 16:43:27 +08:00
|
|
|
const sw = "strokeWidth" in obj.attrs ? a.strokeWidth / 2 : 0;
|
|
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = (obj.attrs as { points: number[] }).points;
|
|
|
|
|
for (let i = 0; i < pts.length; i += 2) {
|
|
|
|
|
minX = Math.min(minX, pts[i] - sw);
|
|
|
|
|
minY = Math.min(minY, pts[i + 1] - sw);
|
|
|
|
|
maxX = Math.max(maxX, pts[i] + sw);
|
|
|
|
|
maxY = Math.max(maxY, pts[i + 1] + sw);
|
|
|
|
|
}
|
|
|
|
|
} else if (isCenterBased(obj)) {
|
|
|
|
|
const cx = "x" in obj.attrs ? a.x : 0;
|
|
|
|
|
const cy = "y" in obj.attrs ? a.y : 0;
|
|
|
|
|
const rx =
|
|
|
|
|
"radiusX" in obj.attrs
|
|
|
|
|
? a.radiusX
|
|
|
|
|
: "radius" in obj.attrs
|
|
|
|
|
? a.radius
|
|
|
|
|
: "outerRadius" in obj.attrs
|
|
|
|
|
? a.outerRadius
|
|
|
|
|
: 0;
|
|
|
|
|
const ry =
|
|
|
|
|
"radiusY" in obj.attrs
|
|
|
|
|
? a.radiusY
|
|
|
|
|
: "radius" in obj.attrs
|
|
|
|
|
? a.radius
|
|
|
|
|
: "outerRadius" in obj.attrs
|
|
|
|
|
? a.outerRadius
|
|
|
|
|
: 0;
|
|
|
|
|
minX = Math.min(minX, cx - rx - sw);
|
|
|
|
|
minY = Math.min(minY, cy - ry - sw);
|
|
|
|
|
maxX = Math.max(maxX, cx + rx + sw);
|
|
|
|
|
maxY = Math.max(maxY, cy + ry + sw);
|
|
|
|
|
} else {
|
|
|
|
|
const x = "x" in obj.attrs ? a.x : 0;
|
|
|
|
|
const y = "y" in obj.attrs ? a.y : 0;
|
|
|
|
|
const w = "width" in obj.attrs ? a.width : 0;
|
|
|
|
|
const h = "height" in obj.attrs ? a.height : 0;
|
|
|
|
|
minX = Math.min(minX, x - sw);
|
|
|
|
|
minY = Math.min(minY, y - sw);
|
|
|
|
|
maxX = Math.max(maxX, x + w + sw);
|
|
|
|
|
maxY = Math.max(maxY, y + h + sw);
|
|
|
|
|
}
|
2026-05-07 23:47:08 +08:00
|
|
|
}
|
|
|
|
|
minX = Math.max(0, Math.floor(minX));
|
|
|
|
|
minY = Math.max(0, Math.floor(minY));
|
|
|
|
|
maxX = Math.min(canvasSize.width, Math.ceil(maxX));
|
|
|
|
|
maxY = Math.min(canvasSize.height, Math.ceil(maxY));
|
|
|
|
|
const newWidth = maxX - minX;
|
|
|
|
|
const newHeight = maxY - minY;
|
|
|
|
|
if (newWidth <= 0 || newHeight <= 0) return;
|
|
|
|
|
if (
|
|
|
|
|
newWidth === canvasSize.width &&
|
|
|
|
|
newHeight === canvasSize.height &&
|
|
|
|
|
minX === 0 &&
|
|
|
|
|
minY === 0
|
|
|
|
|
)
|
|
|
|
|
return;
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
2026-05-07 23:47:08 +08:00
|
|
|
canvasSize: { width: newWidth, height: newHeight },
|
|
|
|
|
sourceImageSize: { width: newWidth, height: newHeight },
|
|
|
|
|
objects: objects.map((obj) => {
|
|
|
|
|
const attrs = { ...obj.attrs };
|
2026-05-08 16:43:27 +08:00
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = [...(attrs as { points: number[] }).points];
|
|
|
|
|
for (let i = 0; i < pts.length; i += 2) {
|
|
|
|
|
pts[i] -= minX;
|
|
|
|
|
pts[i + 1] -= minY;
|
|
|
|
|
}
|
|
|
|
|
(attrs as { points: number[] }).points = pts;
|
|
|
|
|
} else {
|
|
|
|
|
if ("x" in attrs) {
|
|
|
|
|
(attrs as unknown as Record<string, number>).x -= minX;
|
|
|
|
|
}
|
|
|
|
|
if ("y" in attrs) {
|
|
|
|
|
(attrs as unknown as Record<string, number>).y -= minY;
|
|
|
|
|
}
|
2026-05-07 23:47:08 +08:00
|
|
|
}
|
|
|
|
|
return { ...obj, attrs } as CanvasObject;
|
|
|
|
|
}),
|
2026-05-06 23:05:33 +08:00
|
|
|
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 });
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-07 23:47:08 +08:00
|
|
|
setBackgroundColor: (color) => {
|
|
|
|
|
const { recentColors } = get();
|
|
|
|
|
const updated = [color, ...recentColors.filter((c) => c !== color)].slice(
|
|
|
|
|
0,
|
|
|
|
|
MAX_RECENT_COLORS,
|
|
|
|
|
);
|
|
|
|
|
set({ backgroundColor: color, recentColors: updated });
|
|
|
|
|
},
|
2026-05-06 23:05:33 +08:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-07 10:00:12 +08:00
|
|
|
// Issue #11: Don't increment _historyVersion here -- updateObject is called
|
|
|
|
|
// on every mousemove during brush strokes for live preview. History is only
|
|
|
|
|
// recorded when addObject finalizes the stroke.
|
2026-05-06 23:05:33 +08:00
|
|
|
updateObject: (id, attrs) => {
|
|
|
|
|
set({
|
|
|
|
|
objects: get().objects.map((obj) =>
|
|
|
|
|
obj.id === id ? ({ ...obj, attrs: { ...obj.attrs, ...attrs } } as CanvasObject) : obj,
|
|
|
|
|
),
|
|
|
|
|
isDirty: true,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-07 23:47:08 +08:00
|
|
|
const newObjects = objects.filter((o) => o.id !== objectId);
|
|
|
|
|
let insertIdx = newObjects.length;
|
|
|
|
|
for (let i = newObjects.length - 1; i >= 0; i--) {
|
|
|
|
|
if (newObjects[i].layerId === obj.layerId) {
|
|
|
|
|
insertIdx = i + 1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
newObjects.splice(insertIdx, 0, obj);
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
2026-05-07 23:47:08 +08:00
|
|
|
objects: newObjects,
|
2026-05-06 23:05:33 +08:00
|
|
|
lastAction: "Bring to Front",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
bringForward: (objectId) => {
|
|
|
|
|
const { objects } = get();
|
|
|
|
|
const idx = objects.findIndex((o) => o.id === objectId);
|
2026-05-07 23:47:08 +08:00
|
|
|
if (idx === -1) return;
|
|
|
|
|
const obj = objects[idx];
|
|
|
|
|
let swapIdx = -1;
|
|
|
|
|
for (let i = idx + 1; i < objects.length; i++) {
|
|
|
|
|
if (objects[i].layerId === obj.layerId) {
|
|
|
|
|
swapIdx = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (swapIdx === -1) return;
|
2026-05-06 23:05:33 +08:00
|
|
|
const newObjects = [...objects];
|
2026-05-07 23:47:08 +08:00
|
|
|
[newObjects[idx], newObjects[swapIdx]] = [newObjects[swapIdx], newObjects[idx]];
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
|
|
|
|
objects: newObjects,
|
|
|
|
|
lastAction: "Bring Forward",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
sendBackward: (objectId) => {
|
|
|
|
|
const { objects } = get();
|
|
|
|
|
const idx = objects.findIndex((o) => o.id === objectId);
|
2026-05-07 23:47:08 +08:00
|
|
|
if (idx === -1) return;
|
|
|
|
|
const obj = objects[idx];
|
|
|
|
|
let swapIdx = -1;
|
|
|
|
|
for (let i = idx - 1; i >= 0; i--) {
|
|
|
|
|
if (objects[i].layerId === obj.layerId) {
|
|
|
|
|
swapIdx = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (swapIdx === -1) return;
|
2026-05-06 23:05:33 +08:00
|
|
|
const newObjects = [...objects];
|
2026-05-07 23:47:08 +08:00
|
|
|
[newObjects[swapIdx], newObjects[idx]] = [newObjects[idx], newObjects[swapIdx]];
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
|
|
|
|
objects: newObjects,
|
|
|
|
|
lastAction: "Send Backward",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
sendToBack: (objectId) => {
|
2026-05-08 16:43:27 +08:00
|
|
|
const { objects, layers } = get();
|
2026-05-06 23:05:33 +08:00
|
|
|
const obj = objects.find((o) => o.id === objectId);
|
|
|
|
|
if (!obj) return;
|
2026-05-07 23:47:08 +08:00
|
|
|
const newObjects = objects.filter((o) => o.id !== objectId);
|
2026-05-08 16:43:27 +08:00
|
|
|
// Find the first object on the same layer
|
|
|
|
|
let insertIdx = -1;
|
2026-05-07 23:47:08 +08:00
|
|
|
for (let i = 0; i < newObjects.length; i++) {
|
|
|
|
|
if (newObjects[i].layerId === obj.layerId) {
|
|
|
|
|
insertIdx = i;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-08 16:43:27 +08:00
|
|
|
// If no other objects on the same layer, find the correct position
|
|
|
|
|
// based on layer ordering (after all objects from earlier layers)
|
|
|
|
|
if (insertIdx === -1) {
|
|
|
|
|
const layerIdx = layers.findIndex((l) => l.id === obj.layerId);
|
|
|
|
|
insertIdx = 0;
|
|
|
|
|
for (let i = 0; i < newObjects.length; i++) {
|
|
|
|
|
const objLayerIdx = layers.findIndex((l) => l.id === newObjects[i].layerId);
|
|
|
|
|
if (objLayerIdx < layerIdx) {
|
|
|
|
|
insertIdx = i + 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-07 23:47:08 +08:00
|
|
|
newObjects.splice(insertIdx, 0, obj);
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
2026-05-07 23:47:08 +08:00
|
|
|
objects: newObjects,
|
2026-05-06 23:05:33 +08:00
|
|
|
lastAction: "Send to Back",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Layers
|
|
|
|
|
addLayer: () => {
|
|
|
|
|
const id = generateId();
|
2026-05-07 23:47:08 +08:00
|
|
|
const name = `Layer ${nextLayerNumber(get().layers)}`;
|
2026-05-06 23:05:33 +08:00
|
|
|
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();
|
|
|
|
|
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
|
2026-05-07 10:00:12 +08:00
|
|
|
// Issue #7: Include _historyVersion increment for undo tracking
|
2026-05-06 23:05:33 +08:00
|
|
|
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)),
|
|
|
|
|
},
|
2026-05-07 10:00:12 +08:00
|
|
|
isDirty: true,
|
|
|
|
|
lastAction: `Adjust ${key.charAt(0).toUpperCase() + key.slice(1)}`,
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
2026-05-06 23:05:33 +08:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-07 10:00:12 +08:00
|
|
|
resetAdjustments: () =>
|
|
|
|
|
set({
|
|
|
|
|
adjustments: { ...DEFAULT_ADJUSTMENTS },
|
|
|
|
|
isDirty: true,
|
|
|
|
|
lastAction: "Reset Adjustments",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
}),
|
2026-05-06 23:05:33 +08:00
|
|
|
|
|
|
|
|
toggleFilter: (type) => {
|
|
|
|
|
set({
|
|
|
|
|
filters: get().filters.map((f) => (f.type === type ? { ...f, enabled: !f.enabled } : f)),
|
2026-05-07 10:00:12 +08:00
|
|
|
isDirty: true,
|
|
|
|
|
lastAction: `Toggle ${type.charAt(0).toUpperCase() + type.slice(1)} Filter`,
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
2026-05-06 23:05:33 +08:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
setFilterParam: (type, key, value) => {
|
|
|
|
|
set({
|
|
|
|
|
filters: get().filters.map((f) =>
|
|
|
|
|
f.type === type ? { ...f, params: { ...f.params, [key]: value } } : f,
|
|
|
|
|
),
|
2026-05-07 10:00:12 +08:00
|
|
|
isDirty: true,
|
|
|
|
|
lastAction: `Set ${type} ${key}`,
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
2026-05-06 23:05:33 +08:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Selection
|
|
|
|
|
setSelection: (selection) => set({ selection }),
|
2026-05-07 23:47:08 +08:00
|
|
|
setSelectionMode: (mode) => set({ selectionMode: mode }),
|
|
|
|
|
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
|
2026-05-09 00:14:57 +08:00
|
|
|
setMagicWandContiguous: (v: boolean) => set({ magicWandContiguous: v }),
|
2026-05-06 23:05:33 +08:00
|
|
|
|
|
|
|
|
invertSelection: () => {
|
2026-05-08 16:43:27 +08:00
|
|
|
const { selection, canvasSize } = get();
|
2026-05-06 23:05:33 +08:00
|
|
|
if (!selection) return;
|
2026-05-09 00:14:57 +08:00
|
|
|
const oldBounds = selection.bounds;
|
|
|
|
|
const oldMask = selection.mask;
|
|
|
|
|
|
|
|
|
|
const fullW = canvasSize.width;
|
|
|
|
|
const fullH = canvasSize.height;
|
|
|
|
|
const inverted = new Uint8Array(fullW * fullH);
|
|
|
|
|
|
|
|
|
|
if (oldMask) {
|
|
|
|
|
// Start with everything selected
|
|
|
|
|
inverted.fill(1);
|
|
|
|
|
// Clear pixels that were selected in the old mask
|
|
|
|
|
for (let row = 0; row < oldBounds.height; row++) {
|
|
|
|
|
for (let col = 0; col < oldBounds.width; col++) {
|
|
|
|
|
if (oldMask[row * oldBounds.width + col]) {
|
|
|
|
|
const absY = oldBounds.y + row;
|
|
|
|
|
const absX = oldBounds.x + col;
|
|
|
|
|
if (absX >= 0 && absX < fullW && absY >= 0 && absY < fullH) {
|
|
|
|
|
inverted[absY * fullW + absX] = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Geometric selection (rect/ellipse): invert by marking everything
|
|
|
|
|
// outside the bounds as selected
|
|
|
|
|
inverted.fill(1);
|
|
|
|
|
const x0 = Math.max(0, Math.floor(oldBounds.x));
|
|
|
|
|
const y0 = Math.max(0, Math.floor(oldBounds.y));
|
|
|
|
|
const x1 = Math.min(fullW, Math.ceil(oldBounds.x + oldBounds.width));
|
|
|
|
|
const y1 = Math.min(fullH, Math.ceil(oldBounds.y + oldBounds.height));
|
2026-05-08 16:43:27 +08:00
|
|
|
for (let row = y0; row < y1; row++) {
|
|
|
|
|
for (let col = x0; col < x1; col++) {
|
2026-05-09 00:14:57 +08:00
|
|
|
inverted[row * fullW + col] = 0;
|
2026-05-08 16:43:27 +08:00
|
|
|
}
|
2026-05-06 23:05:33 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-09 00:14:57 +08:00
|
|
|
|
|
|
|
|
set({
|
|
|
|
|
selection: {
|
|
|
|
|
...selection,
|
|
|
|
|
type: "wand",
|
|
|
|
|
bounds: { x: 0, y: 0, width: fullW, height: fullH },
|
|
|
|
|
mask: inverted,
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-05-06 23:05:33 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Crop
|
|
|
|
|
setCropState: (state) => set({ cropState: state, isCropping: state !== null }),
|
|
|
|
|
|
|
|
|
|
applyCrop: () => {
|
2026-05-08 16:43:27 +08:00
|
|
|
const { cropState, objects, sourceImageUrl } = get();
|
2026-05-06 23:05:33 +08:00
|
|
|
if (!cropState) return;
|
2026-05-08 16:43:27 +08:00
|
|
|
|
|
|
|
|
// Crop the source image via an offscreen canvas
|
|
|
|
|
if (sourceImageUrl) {
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
const offscreen = document.createElement("canvas");
|
|
|
|
|
offscreen.width = cropState.width;
|
|
|
|
|
offscreen.height = cropState.height;
|
|
|
|
|
const ctx = offscreen.getContext("2d");
|
|
|
|
|
if (ctx) {
|
|
|
|
|
ctx.drawImage(img, -cropState.x, -cropState.y);
|
|
|
|
|
const croppedUrl = offscreen.toDataURL("image/png");
|
|
|
|
|
const oldUrl = get().sourceImageUrl;
|
|
|
|
|
if (oldUrl?.startsWith("blob:")) {
|
|
|
|
|
URL.revokeObjectURL(oldUrl);
|
|
|
|
|
}
|
|
|
|
|
set({ sourceImageUrl: croppedUrl });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
img.src = sourceImageUrl;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-06 23:05:33 +08:00
|
|
|
set({
|
|
|
|
|
canvasSize: { width: cropState.width, height: cropState.height },
|
2026-05-07 23:47:08 +08:00
|
|
|
sourceImageSize: { width: cropState.width, height: cropState.height },
|
2026-05-06 23:05:33 +08:00
|
|
|
objects: objects.map((obj) => {
|
|
|
|
|
const attrs = { ...obj.attrs };
|
2026-05-08 16:43:27 +08:00
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = [...(attrs as { points: number[] }).points];
|
|
|
|
|
for (let i = 0; i < pts.length; i += 2) {
|
|
|
|
|
pts[i] -= cropState.x;
|
|
|
|
|
pts[i + 1] -= cropState.y;
|
|
|
|
|
}
|
|
|
|
|
(attrs as { points: number[] }).points = pts;
|
|
|
|
|
} else {
|
|
|
|
|
if ("x" in attrs) {
|
|
|
|
|
(attrs as { x: number }).x -= cropState.x;
|
|
|
|
|
}
|
|
|
|
|
if ("y" in attrs) {
|
|
|
|
|
(attrs as { y: number }).y -= cropState.y;
|
|
|
|
|
}
|
2026-05-06 23:05:33 +08:00
|
|
|
}
|
|
|
|
|
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: () => {
|
2026-05-08 16:43:27 +08:00
|
|
|
const state = get();
|
|
|
|
|
const selected = state.objects.filter((o) => state.selectedObjectIds.includes(o.id));
|
|
|
|
|
if (!selected.length) return;
|
|
|
|
|
const idSet = new Set(state.selectedObjectIds);
|
|
|
|
|
set({
|
|
|
|
|
clipboard: selected,
|
|
|
|
|
objects: state.objects.filter((o) => !idSet.has(o.id)),
|
|
|
|
|
selectedObjectIds: [],
|
|
|
|
|
isDirty: true,
|
|
|
|
|
lastAction: "Cut",
|
|
|
|
|
_historyVersion: state._historyVersion + 1,
|
|
|
|
|
});
|
2026-05-06 23:05:33 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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 }),
|
|
|
|
|
|
2026-05-07 23:47:08 +08:00
|
|
|
// Text
|
|
|
|
|
setEditingTextId: (id) => set({ editingTextId: id }),
|
|
|
|
|
|
|
|
|
|
// Dodge/Burn/Sponge settings
|
|
|
|
|
setDodgeBurnRange: (range) => set({ dodgeBurnRange: range }),
|
|
|
|
|
setDodgeBurnExposure: (exposure) => set({ dodgeBurnExposure: exposure }),
|
|
|
|
|
setSpongeMode: (mode) => set({ spongeMode: mode }),
|
|
|
|
|
setSpongeFlow: (flow) => set({ spongeFlow: flow }),
|
|
|
|
|
|
|
|
|
|
// Shape settings
|
|
|
|
|
setShapeFill: (fill) => set({ shapeFill: fill }),
|
|
|
|
|
setShapeStroke: (stroke) => set({ shapeStroke: stroke }),
|
|
|
|
|
setShapeStrokeWidth: (width) => set({ shapeStrokeWidth: width }),
|
|
|
|
|
setShapeCornerRadius: (radius) => set({ shapeCornerRadius: radius }),
|
|
|
|
|
setShapePolygonSides: (sides) => set({ shapePolygonSides: sides }),
|
|
|
|
|
setShapeStarPoints: (points) => set({ shapeStarPoints: points }),
|
|
|
|
|
|
|
|
|
|
// Clone stamp
|
|
|
|
|
setCloneSource: (source) => set({ cloneSource: source }),
|
|
|
|
|
setCloneAligned: (aligned) => set({ cloneAligned: aligned }),
|
|
|
|
|
|
|
|
|
|
// Fill tool settings
|
|
|
|
|
setFillTolerance: (tolerance) =>
|
|
|
|
|
set({ fillTolerance: Math.max(0, Math.min(255, tolerance)) }),
|
|
|
|
|
setFillContiguous: (contiguous) => set({ fillContiguous: contiguous }),
|
|
|
|
|
|
|
|
|
|
// Gradient tool settings
|
|
|
|
|
setGradientType: (type) => set({ gradientType: type }),
|
|
|
|
|
setGradientOpacity: (opacity) => set({ gradientOpacity: Math.max(0, Math.min(1, opacity)) }),
|
|
|
|
|
setGradientReverse: (reverse) => set({ gradientReverse: reverse }),
|
|
|
|
|
|
|
|
|
|
// Pixel brush settings
|
|
|
|
|
setPixelBrushStrength: (strength) =>
|
|
|
|
|
set({ pixelBrushStrength: Math.max(1, Math.min(100, strength)) }),
|
|
|
|
|
|
2026-05-08 16:43:27 +08:00
|
|
|
// History commit (for operations like nudge that use updateObject
|
|
|
|
|
// but still need an undo point)
|
|
|
|
|
commitHistory: (action) => {
|
|
|
|
|
set({
|
|
|
|
|
lastAction: action,
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Batch nudge: move multiple objects and create a history entry
|
|
|
|
|
batchNudge: (objectIds, dx, dy) => {
|
|
|
|
|
const idSet = new Set(objectIds);
|
|
|
|
|
set({
|
|
|
|
|
objects: get().objects.map((obj) => {
|
|
|
|
|
if (!idSet.has(obj.id)) return obj;
|
|
|
|
|
const attrs = { ...obj.attrs };
|
|
|
|
|
if (hasPointsArray(obj)) {
|
|
|
|
|
const pts = [...(attrs as { points: number[] }).points];
|
|
|
|
|
for (let i = 0; i < pts.length; i += 2) {
|
|
|
|
|
pts[i] += dx;
|
|
|
|
|
pts[i + 1] += dy;
|
|
|
|
|
}
|
|
|
|
|
(attrs as { points: number[] }).points = pts;
|
|
|
|
|
} else {
|
|
|
|
|
if ("x" in attrs) {
|
|
|
|
|
(attrs as unknown as Record<string, number>).x += dx;
|
|
|
|
|
}
|
|
|
|
|
if ("y" in attrs) {
|
|
|
|
|
(attrs as unknown as Record<string, number>).y += dy;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { ...obj, attrs } as CanvasObject;
|
|
|
|
|
}),
|
|
|
|
|
isDirty: true,
|
|
|
|
|
lastAction: "Nudge",
|
|
|
|
|
_historyVersion: get()._historyVersion + 1,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Layer thumbnail update (actual generation happens in the canvas component)
|
|
|
|
|
updateLayerThumbnail: (layerId, thumbnailDataUrl) => {
|
|
|
|
|
set({
|
|
|
|
|
layers: get().layers.map((l) =>
|
|
|
|
|
l.id === layerId ? { ...l, thumbnail: thumbnailDataUrl } : l,
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
2026-05-06 23:05:33 +08:00
|
|
|
// Right panel
|
|
|
|
|
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
|
|
|
|
|
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),
|
|
|
|
|
}),
|
|
|
|
|
{
|
2026-05-07 10:00:12 +08:00
|
|
|
// Issue #8: Include lastAction in partialize so history labels work
|
2026-05-06 23:05:33 +08:00
|
|
|
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,
|
2026-05-07 10:00:12 +08:00
|
|
|
lastAction: state.lastAction,
|
2026-05-06 23:05:33 +08:00
|
|
|
_historyVersion: state._historyVersion,
|
|
|
|
|
}),
|
|
|
|
|
limit: MAX_HISTORY,
|
|
|
|
|
equality: (a, b) =>
|
|
|
|
|
(a as { _historyVersion: number })._historyVersion ===
|
|
|
|
|
(b as { _historyVersion: number })._historyVersion,
|
2026-05-07 20:21:46 +08:00
|
|
|
// Issue #1: Forward all arguments from zundo's internal _handleSet.
|
|
|
|
|
// No debounce -- the equality function (based on _historyVersion) already
|
|
|
|
|
// prevents intermediate states from being recorded. Debouncing caused
|
|
|
|
|
// undo/redo to race with the delayed recording and silently discard the
|
|
|
|
|
// future-states stack.
|
2026-05-06 23:05:33 +08:00
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
);
|