{/* Type toggle */}
@@ -81,11 +87,7 @@ export function ShapeOptions() {
min={0}
max={50}
value={shapeStrokeWidth}
- onChange={(e) =>
- useEditorStore.setState({
- shapeStrokeWidth: Number(e.target.value),
- })
- }
+ onChange={(e) => setShapeStrokeWidth(Number(e.target.value))}
className="w-16 h-1 accent-primary"
/>
- useEditorStore.setState({
- shapeStrokeWidth: Number(e.target.value),
- })
- }
+ onChange={(e) => setShapeStrokeWidth(Number(e.target.value))}
className="w-10 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
@@ -111,11 +109,7 @@ export function ShapeOptions() {
min={0}
max={100}
value={shapeCornerRadius}
- onChange={(e) =>
- useEditorStore.setState({
- shapeCornerRadius: Number(e.target.value),
- })
- }
+ onChange={(e) => setShapeCornerRadius(Number(e.target.value))}
className="w-16 h-1 accent-primary"
/>
- useEditorStore.setState({
- shapeCornerRadius: Number(e.target.value),
- })
- }
+ onChange={(e) => setShapeCornerRadius(Number(e.target.value))}
className="w-10 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
@@ -142,11 +132,7 @@ export function ShapeOptions() {
min={3}
max={20}
value={shapePolygonSides}
- onChange={(e) =>
- useEditorStore.setState({
- shapePolygonSides: Number(e.target.value),
- })
- }
+ onChange={(e) => setShapePolygonSides(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
@@ -161,11 +147,7 @@ export function ShapeOptions() {
min={3}
max={20}
value={shapeStarPoints}
- onChange={(e) =>
- useEditorStore.setState({
- shapeStarPoints: Number(e.target.value),
- })
- }
+ onChange={(e) => setShapeStarPoints(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
diff --git a/apps/web/src/components/editor/options/text-options.tsx b/apps/web/src/components/editor/options/text-options.tsx
index 33e26dbe..ce7f5b98 100644
--- a/apps/web/src/components/editor/options/text-options.tsx
+++ b/apps/web/src/components/editor/options/text-options.tsx
@@ -16,6 +16,27 @@ import { useEditorStore } from "@/stores/editor-store";
import type { TextAttrs } from "@/types/editor";
import { getAllFonts, isSystemFont, loadGoogleFont } from "../common/font-loader";
+// ---------------------------------------------------------------------------
+// Default attrs used when no text object is selected (next-text settings)
+// ---------------------------------------------------------------------------
+
+const DEFAULT_TEXT_ATTRS: TextAttrs = {
+ x: 0,
+ y: 0,
+ text: "",
+ fontFamily: "Arial",
+ fontSize: 24,
+ fontStyle: "normal",
+ fontVariant: "normal",
+ textDecoration: "",
+ align: "left",
+ fill: "#000000",
+ lineHeight: 1.2,
+ letterSpacing: 0,
+ rotation: 0,
+ opacity: 1,
+};
+
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -213,14 +234,14 @@ export function TextOptions() {
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const objects = useEditorStore((s) => s.objects);
- // Derive current attrs from the selected text object
- const attrs = useMemo(() => {
+ // Derive current attrs from the selected text object, fall back to defaults
+ const selectedAttrs = useMemo(() => {
if (selectedObjectIds.length !== 1) return null;
const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text");
return obj ? (obj.attrs as TextAttrs) : null;
}, [selectedObjectIds, objects]);
- if (!attrs) return null;
+ const attrs = selectedAttrs ?? DEFAULT_TEXT_ATTRS;
const isBold = attrs.fontStyle.includes("bold");
const isItalic = attrs.fontStyle.includes("italic");
@@ -252,7 +273,7 @@ export function TextOptions() {
const had = current.textDecoration.includes("underline");
const parts = current.textDecoration.split(" ").filter((p) => p !== "underline" && p !== "");
if (!had) parts.push("underline");
- updateSelected({ textDecoration: parts.join(" ") });
+ updateSelected({ textDecoration: parts.join(" ") || "none" });
};
const toggleStrikethrough = () => {
@@ -261,7 +282,7 @@ export function TextOptions() {
const had = current.textDecoration.includes("line-through");
const parts = current.textDecoration.split(" ").filter((p) => p !== "line-through" && p !== "");
if (!had) parts.push("line-through");
- updateSelected({ textDecoration: parts.join(" ") });
+ updateSelected({ textDecoration: parts.join(" ") || "none" });
};
const toggleTextMode = () => {
diff --git a/apps/web/src/components/editor/panels/adjustments-panel.tsx b/apps/web/src/components/editor/panels/adjustments-panel.tsx
index 44880e17..9dd7bd8c 100644
--- a/apps/web/src/components/editor/panels/adjustments-panel.tsx
+++ b/apps/web/src/components/editor/panels/adjustments-panel.tsx
@@ -12,8 +12,6 @@ import type { AdjustmentValues } from "@/types/editor";
// Constants
// ---------------------------------------------------------------------------
-const DEBOUNCE_MS = 150;
-
const ADJUSTMENT_SLIDERS: {
key: keyof AdjustmentValues;
label: string;
@@ -378,28 +376,13 @@ function AutoAdjustmentsSection() {
function AdjustmentsSlidersSection() {
const adjustments = useEditorStore((s) => s.adjustments);
const setAdjustment = useEditorStore((s) => s.setAdjustment);
- const debounceRef = useRef
>(null);
-
const handleChange = useCallback(
(key: keyof AdjustmentValues, value: number) => {
- if (debounceRef.current) {
- clearTimeout(debounceRef.current);
- }
- debounceRef.current = setTimeout(() => {
- setAdjustment(key, value);
- }, DEBOUNCE_MS);
- // Immediately set for responsive UI
setAdjustment(key, value);
},
[setAdjustment],
);
- useEffect(() => {
- return () => {
- if (debounceRef.current) clearTimeout(debounceRef.current);
- };
- }, []);
-
return (
{ADJUSTMENT_SLIDERS.map(({ key, label, min, max }) => (
@@ -778,6 +761,8 @@ function CurvesSection() {
const pts = [...prev[channel]];
pts[draggingIndex] = pos;
pts.sort((a, b) => a.x - b.x);
+ const newIndex = pts.findIndex((p) => p.x === pos.x && p.y === pos.y);
+ if (newIndex !== -1) setDraggingIndex(newIndex);
return { ...prev, [channel]: pts };
});
},
@@ -1058,15 +1043,24 @@ export function AdjustmentsPanel() {
}, [adjustments, filters]);
const handleResetAll = useCallback(() => {
- resetAdjustments();
- // Reset all filters by toggling off any enabled ones
const store = useEditorStore.getState();
- for (const f of store.filters) {
- if (f.enabled) {
- store.toggleFilter(f.type);
- }
- }
- }, [resetAdjustments]);
+ useEditorStore.setState({
+ adjustments: {
+ brightness: 0,
+ contrast: 0,
+ hue: 0,
+ saturation: 0,
+ luminance: 0,
+ exposure: 0,
+ vibrance: 0,
+ warmth: 0,
+ },
+ filters: store.filters.map((f) => ({ ...f, enabled: false })),
+ isDirty: true,
+ lastAction: "Reset All",
+ _historyVersion: store._historyVersion + 1,
+ });
+ }, []);
const handleApply = useCallback(() => {
// Bake adjustments and filters into pixel data
diff --git a/apps/web/src/components/editor/panels/color-panel.tsx b/apps/web/src/components/editor/panels/color-panel.tsx
index 4a990c31..7bc0c20d 100644
--- a/apps/web/src/components/editor/panels/color-panel.tsx
+++ b/apps/web/src/components/editor/panels/color-panel.tsx
@@ -74,11 +74,13 @@ function hslToHex(h: number, s: number, l: number): string {
gn = x;
bn = c;
} else if (h < 300) {
- rn = c;
- bn = x;
- } else {
rn = x;
+ gn = 0;
bn = c;
+ } else {
+ rn = c;
+ gn = 0;
+ bn = x;
}
const r = Math.round((rn + m) * 255);
@@ -305,10 +307,11 @@ export function ColorPanel() {
const [pickerTarget, setPickerTarget] = useState
(null);
const [hexInput, setHexInput] = useState(foregroundColor);
- // Keep hex input in sync with store
+ // Keep hex input in sync with the active color based on pickerTarget
useEffect(() => {
- setHexInput(foregroundColor);
- }, [foregroundColor]);
+ const color = pickerTarget === "bg" ? backgroundColor : foregroundColor;
+ setHexInput(color);
+ }, [foregroundColor, backgroundColor, pickerTarget]);
const activeColor = pickerTarget === "bg" ? backgroundColor : foregroundColor;
const setActiveColor = pickerTarget === "bg" ? setBackgroundColor : setForegroundColor;
@@ -333,16 +336,17 @@ export function ColorPanel() {
setHexInput(v);
if (!v.startsWith("#")) v = `#${v}`;
if (isValidHex(v)) {
- setForegroundColor(v.toLowerCase());
+ setActiveColor(v.toLowerCase());
}
},
- [setForegroundColor],
+ [setActiveColor],
);
const handleHexInputBlur = useCallback(() => {
- // Reset to current foreground if invalid
- setHexInput(foregroundColor);
- }, [foregroundColor]);
+ // Reset to current active color if invalid
+ const color = pickerTarget === "bg" ? backgroundColor : foregroundColor;
+ setHexInput(color);
+ }, [foregroundColor, backgroundColor, pickerTarget]);
return (
diff --git a/apps/web/src/components/editor/panels/history-panel.tsx b/apps/web/src/components/editor/panels/history-panel.tsx
index c664310f..fe4c0bd7 100644
--- a/apps/web/src/components/editor/panels/history-panel.tsx
+++ b/apps/web/src/components/editor/panels/history-panel.tsx
@@ -81,9 +81,6 @@ interface HistoryEntry {
export function HistoryPanel() {
const lastAction = useEditorStore((s) => s.lastAction);
- const temporalStore = useEditorStore.temporal.getState();
- const pastStates = temporalStore.pastStates;
- const futureStates = temporalStore.futureStates;
// Force re-render when history changes by subscribing to history version
useEditorStore((s) => s._historyVersion);
@@ -154,10 +151,10 @@ export function HistoryPanel() {
{/* History list */}
diff --git a/apps/web/src/components/editor/panels/navigator-panel.tsx b/apps/web/src/components/editor/panels/navigator-panel.tsx
index 91b1876b..0426229a 100644
--- a/apps/web/src/components/editor/panels/navigator-panel.tsx
+++ b/apps/web/src/components/editor/panels/navigator-panel.tsx
@@ -196,8 +196,8 @@ export function NavigatorPanel() {
const handleZoomSlider = useCallback(
(e: React.ChangeEvent) => {
- const val = Number.parseFloat(e.target.value);
- setZoom(val);
+ const t = Number.parseFloat(e.target.value);
+ setZoom(MIN_ZOOM * (MAX_ZOOM / MIN_ZOOM) ** t);
},
[setZoom],
);
@@ -252,10 +252,10 @@ export function NavigatorPanel() {
) {
const stampRef = useRef(null);
const initialOffsetRef = useRef<{ x: number; y: number } | null>(null);
+ const setCloneSource = useEditorStore((s) => s.setCloneSource);
const handleMouseDown = useCallback(
(e: Konva.KonvaEventObject) => {
- const { activeTool, cloneSource, brushSize, brushOpacity, canvasSize, zoom, panOffset } =
- useEditorStore.getState();
+ const {
+ activeTool,
+ cloneSource,
+ cloneAligned,
+ brushSize,
+ brushOpacity,
+ canvasSize,
+ zoom,
+ panOffset,
+ } = useEditorStore.getState();
if (activeTool !== "clone-stamp") return;
@@ -49,9 +48,7 @@ export function useCloneStampTool(stageRef: React.RefObject)
// Alt+click sets the clone source
if (e.evt.altKey) {
- useEditorStore.setState({
- cloneSource: { x, y, aligned: cloneAligned },
- });
+ setCloneSource({ x, y, aligned: cloneAligned });
initialOffsetRef.current = null;
return;
}
@@ -127,7 +124,7 @@ export function useCloneStampTool(stageRef: React.RefObject)
offsetY,
};
},
- [stageRef],
+ [stageRef, setCloneSource],
);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject) => {
diff --git a/apps/web/src/components/editor/tools/crop-tool.tsx b/apps/web/src/components/editor/tools/crop-tool.tsx
index 0026646c..57f45707 100644
--- a/apps/web/src/components/editor/tools/crop-tool.tsx
+++ b/apps/web/src/components/editor/tools/crop-tool.tsx
@@ -188,7 +188,7 @@ export function CropOverlay() {
};
return (
-
+
{/* Darkened overlays: top, bottom, left, right */}
s.setForegroundColor);
const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor);
+ const zoom = useEditorStore((s) => s.zoom);
+ const panOffset = useEditorStore((s) => s.panOffset);
const [sampledColor, setSampledColor] = useState(null);
const canvasCache = useRef(null);
@@ -96,9 +98,9 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt
const ctx = canvas.getContext("2d");
if (!ctx) return null;
- // pointer coordinates are already in stage pixel space
- const x = Math.round(pointer.x);
- const y = Math.round(pointer.y);
+ // Transform pointer coordinates from screen space to canvas space
+ const x = Math.round((pointer.x - panOffset.x) / zoom);
+ const y = Math.round((pointer.y - panOffset.y) / zoom);
// Bounds check
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) {
@@ -109,7 +111,7 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt
setSampledColor(color);
return color;
},
- [getStageCanvas, sampleSize],
+ [getStageCanvas, sampleSize, zoom, panOffset],
);
/**
diff --git a/apps/web/src/components/editor/tools/fill-tool.tsx b/apps/web/src/components/editor/tools/fill-tool.tsx
index feb17493..544681f6 100644
--- a/apps/web/src/components/editor/tools/fill-tool.tsx
+++ b/apps/web/src/components/editor/tools/fill-tool.tsx
@@ -6,26 +6,6 @@ import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
-/** Tolerance for flood fill (0-255). Stored outside store for simplicity. */
-let fillTolerance = 32;
-let fillContiguous = true;
-
-export function setFillTolerance(value: number) {
- fillTolerance = Math.max(0, Math.min(255, value));
-}
-
-export function getFillTolerance(): number {
- return fillTolerance;
-}
-
-export function setFillContiguous(value: boolean) {
- fillContiguous = value;
-}
-
-export function getFillContiguous(): boolean {
- return fillContiguous;
-}
-
function colorDistance(
r1: number,
g1: number,
@@ -157,8 +137,15 @@ function floodFill(
export function useFillTool(stageRef: React.RefObject) {
const handleMouseDown = useCallback(
(_e: Konva.KonvaEventObject) => {
- const { activeTool, foregroundColor, canvasSize, zoom, panOffset } =
- useEditorStore.getState();
+ const {
+ activeTool,
+ foregroundColor,
+ fillTolerance,
+ fillContiguous,
+ canvasSize,
+ zoom,
+ panOffset,
+ } = useEditorStore.getState();
if (activeTool !== "fill") return;
diff --git a/apps/web/src/components/editor/tools/gradient-tool.tsx b/apps/web/src/components/editor/tools/gradient-tool.tsx
index ae41eecd..16f7bfa4 100644
--- a/apps/web/src/components/editor/tools/gradient-tool.tsx
+++ b/apps/web/src/components/editor/tools/gradient-tool.tsx
@@ -6,36 +6,6 @@ import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
-export type GradientType = "linear" | "radial";
-
-let gradientType: GradientType = "linear";
-let gradientOpacity = 1;
-let gradientReverse = false;
-
-export function setGradientType(type: GradientType) {
- gradientType = type;
-}
-
-export function getGradientType(): GradientType {
- return gradientType;
-}
-
-export function setGradientOpacity(value: number) {
- gradientOpacity = Math.max(0, Math.min(1, value));
-}
-
-export function getGradientOpacity(): number {
- return gradientOpacity;
-}
-
-export function setGradientReverse(value: boolean) {
- gradientReverse = value;
-}
-
-export function getGradientReverse(): boolean {
- return gradientReverse;
-}
-
interface DragState {
startX: number;
startY: number;
@@ -68,8 +38,16 @@ export function useGradientTool() {
const handleMouseUp = useCallback((e: Konva.KonvaEventObject) => {
if (!dragRef.current) return;
- const { foregroundColor, backgroundColor, canvasSize, zoom, panOffset } =
- useEditorStore.getState();
+ const {
+ foregroundColor,
+ backgroundColor,
+ gradientType,
+ gradientOpacity,
+ gradientReverse,
+ canvasSize,
+ zoom,
+ panOffset,
+ } = useEditorStore.getState();
const stage = e.target.getStage();
if (!stage) return;
diff --git a/apps/web/src/components/editor/tools/move-tool.tsx b/apps/web/src/components/editor/tools/move-tool.tsx
index ecd3112a..523ee057 100644
--- a/apps/web/src/components/editor/tools/move-tool.tsx
+++ b/apps/web/src/components/editor/tools/move-tool.tsx
@@ -157,9 +157,10 @@ export function alignObjects(
objectIds: string[],
objects: { id: string; attrs: Record }[],
updateObject: (id: string, attrs: Record) => void,
+ canvasSize?: { width: number; height: number },
): void {
const selected = objects.filter((o) => objectIds.includes(o.id));
- if (selected.length < 2 && !direction.startsWith("distribute")) return;
+ if (selected.length === 0) return;
if (selected.length < 3 && direction.startsWith("distribute")) return;
const bounds = selected.map((o) => ({
@@ -170,6 +171,34 @@ export function alignObjects(
h: (o.attrs.height as number) ?? 0,
}));
+ // Single object: align relative to canvas bounds
+ if (selected.length === 1 && canvasSize) {
+ const b = bounds[0];
+ switch (direction) {
+ case "left":
+ updateObject(b.id, { x: 0 });
+ break;
+ case "center-h":
+ updateObject(b.id, { x: canvasSize.width / 2 - b.w / 2 });
+ break;
+ case "right":
+ updateObject(b.id, { x: canvasSize.width - b.w });
+ break;
+ case "top":
+ updateObject(b.id, { y: 0 });
+ break;
+ case "center-v":
+ updateObject(b.id, { y: canvasSize.height / 2 - b.h / 2 });
+ break;
+ case "bottom":
+ updateObject(b.id, { y: canvasSize.height - b.h });
+ break;
+ }
+ return;
+ }
+
+ if (selected.length < 2) return;
+
switch (direction) {
case "left": {
const minX = Math.min(...bounds.map((b) => b.x));
diff --git a/apps/web/src/components/editor/tools/pixel-brush-tool.tsx b/apps/web/src/components/editor/tools/pixel-brush-tool.tsx
index 68e0c9b3..ee0e871e 100644
--- a/apps/web/src/components/editor/tools/pixel-brush-tool.tsx
+++ b/apps/web/src/components/editor/tools/pixel-brush-tool.tsx
@@ -8,16 +8,6 @@ import type { CanvasObject, ToolType } from "@/types/editor";
const PIXEL_BRUSH_TOOLS = new Set(["blur-brush", "sharpen-brush", "smudge"]);
-let pixelBrushStrength = 50;
-
-export function setPixelBrushStrength(value: number) {
- pixelBrushStrength = Math.max(1, Math.min(100, value));
-}
-
-export function getPixelBrushStrength(): number {
- return pixelBrushStrength;
-}
-
interface StrokeState {
objectId: string;
canvas: HTMLCanvasElement;
@@ -141,7 +131,7 @@ function applyPixelBrush(
centerY: number,
canvasSize: { width: number; height: number },
): void {
- const { activeTool, brushSize } = useEditorStore.getState();
+ const { activeTool, brushSize, pixelBrushStrength } = useEditorStore.getState();
const strength = pixelBrushStrength / 100;
const halfSize = Math.floor(brushSize / 2);
diff --git a/apps/web/src/components/editor/tools/selection-tool.tsx b/apps/web/src/components/editor/tools/selection-tool.tsx
index cbf2806a..0e5b8de7 100644
--- a/apps/web/src/components/editor/tools/selection-tool.tsx
+++ b/apps/web/src/components/editor/tools/selection-tool.tsx
@@ -2,9 +2,8 @@ import type Konva from "konva";
import { useCallback, useEffect, useRef, useState } from "react";
import { Ellipse, Group, Line, Rect } from "react-konva";
import { useEditorStore } from "@/stores/editor-store";
-import type { SelectionState } from "@/types/editor";
+import type { SelectionMode, SelectionState } from "@/types/editor";
-type SelectionMode = "new" | "add" | "subtract";
type SelectionType = "rect" | "ellipse" | "lasso";
// ---------------------------------------------------------------------------
@@ -240,7 +239,7 @@ export function useSelectionTool(): SelectionToolApi {
const [currentPoints, setCurrentPoints] = useState([]);
const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
- const [selectionMode] = useState("new");
+ const selectionMode = useEditorStore((s) => s.selectionMode);
const setSelection = useEditorStore((s) => s.setSelection);
const canvasSize = useEditorStore((s) => s.canvasSize);
diff --git a/apps/web/src/components/editor/tools/shape-tool.tsx b/apps/web/src/components/editor/tools/shape-tool.tsx
index 5bd68d85..1786b051 100644
--- a/apps/web/src/components/editor/tools/shape-tool.tsx
+++ b/apps/web/src/components/editor/tools/shape-tool.tsx
@@ -6,6 +6,13 @@ import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, ToolType } from "@/types/editor";
+interface PendingShape {
+ startX: number;
+ startY: number;
+ toolType: ToolType;
+ obj: CanvasObject;
+}
+
interface DragState {
startX: number;
startY: number;
@@ -41,6 +48,9 @@ function constrainToDimension(
export function useShapeTool() {
const dragRef = useRef(null);
+ const pendingRef = useRef(null);
+
+ const MIN_DRAG_THRESHOLD = 2;
const handleMouseDown = useCallback((e: Konva.KonvaEventObject) => {
const stage = e.target.getStage();
@@ -183,11 +193,34 @@ export function useShapeTool() {
return;
}
- useEditorStore.getState().addObject(obj);
- dragRef.current = { startX: x, startY: y, objectId: id, toolType: activeTool };
+ // Don't add the object yet -- wait until the user drags past the threshold
+ pendingRef.current = { startX: x, startY: y, toolType: activeTool, obj };
}, []);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject) => {
+ // If we have a pending shape but haven't committed it yet, check threshold
+ if (pendingRef.current && !dragRef.current) {
+ const stage = e.target.getStage();
+ if (!stage) return;
+
+ const { zoom, panOffset } = useEditorStore.getState();
+ const pointer = stage.getPointerPosition();
+ if (!pointer) return;
+
+ const x = (pointer.x - panOffset.x) / zoom;
+ const y = (pointer.y - panOffset.y) / zoom;
+ const dx = x - pendingRef.current.startX;
+ const dy = y - pendingRef.current.startY;
+
+ if (Math.abs(dx) < MIN_DRAG_THRESHOLD && Math.abs(dy) < MIN_DRAG_THRESHOLD) return;
+
+ // Threshold exceeded -- add the object to the store and promote to active drag
+ const { obj, startX, startY, toolType } = pendingRef.current;
+ useEditorStore.getState().addObject(obj);
+ dragRef.current = { startX, startY, objectId: obj.id, toolType };
+ pendingRef.current = null;
+ }
+
if (!dragRef.current) return;
const stage = e.target.getStage();
@@ -280,6 +313,11 @@ export function useShapeTool() {
}, []);
const handleMouseUp = useCallback(() => {
+ // Click without drag -- pending shape was never added, just discard it
+ if (pendingRef.current) {
+ pendingRef.current = null;
+ }
+
if (!dragRef.current) return;
const { objectId } = dragRef.current;
@@ -287,7 +325,7 @@ export function useShapeTool() {
const obj = objects.find((o) => o.id === objectId);
if (obj) {
- // Remove zero-size shapes
+ // Remove degenerate shapes that are still too small
const attrs = obj.attrs;
let isDegenerate = false;
if ("width" in attrs && "height" in attrs) {
diff --git a/apps/web/src/components/editor/tools/text-tool.tsx b/apps/web/src/components/editor/tools/text-tool.tsx
index 72e1f2c8..900d3e5b 100644
--- a/apps/web/src/components/editor/tools/text-tool.tsx
+++ b/apps/web/src/components/editor/tools/text-tool.tsx
@@ -14,6 +14,7 @@ import type { CanvasObject, TextAttrs } from "@/types/editor";
*/
export function useTextTool() {
const textareaRef = useRef(null);
+ const abortRef = useRef(null);
const pendingRef = useRef<{
objectId: string;
x: number;
@@ -23,6 +24,8 @@ export function useTextTool() {
// Clean up any lingering textarea on unmount
useEffect(() => {
return () => {
+ abortRef.current?.abort();
+ abortRef.current = null;
textareaRef.current?.remove();
textareaRef.current = null;
};
@@ -34,6 +37,8 @@ export function useTextTool() {
if (!textarea || !pending) return;
const text = textarea.value.trim();
+ abortRef.current?.abort();
+ abortRef.current = null;
textarea.remove();
textareaRef.current = null;
pendingRef.current = null;
@@ -114,7 +119,7 @@ export function useTextTool() {
const containerRect = container.getBoundingClientRect();
const textarea = document.createElement("textarea");
- textarea.style.position = "absolute";
+ textarea.style.position = "fixed";
textarea.style.left = `${pointer.x + containerRect.left}px`;
textarea.style.top = `${pointer.y + containerRect.top}px`;
textarea.style.fontSize = `${fontSize * zoom}px`;
@@ -138,6 +143,10 @@ export function useTextTool() {
document.body.appendChild(textarea);
textareaRef.current = textarea;
+ const ac = new AbortController();
+ abortRef.current = ac;
+ const { signal } = ac;
+
// Auto-resize as user types
const autoResize = () => {
textarea.style.height = "auto";
@@ -146,24 +155,32 @@ export function useTextTool() {
textarea.style.width = `${Math.max(60, textarea.scrollWidth + 4)}px`;
};
- textarea.addEventListener("input", autoResize);
+ textarea.addEventListener("input", autoResize, { signal });
- textarea.addEventListener("blur", () => {
- commitText();
- });
+ textarea.addEventListener(
+ "blur",
+ () => {
+ commitText();
+ },
+ { signal },
+ );
- textarea.addEventListener("keydown", (ke) => {
- // Enter without Shift commits; Shift+Enter inserts newline
- if (ke.key === "Enter" && !ke.shiftKey) {
- ke.preventDefault();
- textarea.blur();
- }
- if (ke.key === "Escape") {
- ke.preventDefault();
- textarea.value = "";
- textarea.blur();
- }
- });
+ textarea.addEventListener(
+ "keydown",
+ (ke) => {
+ // Enter without Shift commits; Shift+Enter inserts newline
+ if (ke.key === "Enter" && !ke.shiftKey) {
+ ke.preventDefault();
+ textarea.blur();
+ }
+ if (ke.key === "Escape") {
+ ke.preventDefault();
+ textarea.value = "";
+ textarea.blur();
+ }
+ },
+ { signal },
+ );
// Focus after a microtask so the click doesn't immediately blur
requestAnimationFrame(() => textarea.focus());
diff --git a/apps/web/src/hooks/use-canvas-zoom.ts b/apps/web/src/hooks/use-canvas-zoom.ts
index 91ac3e79..67652d16 100644
--- a/apps/web/src/hooks/use-canvas-zoom.ts
+++ b/apps/web/src/hooks/use-canvas-zoom.ts
@@ -26,6 +26,10 @@ export function useCanvasZoom() {
if (tweenRef.current) {
tweenRef.current.destroy();
}
+ // Update store immediately so all tools get correct coordinates
+ setZoom(targetZoom);
+ setPanOffset(targetPos);
+
tweenRef.current = new Konva.Tween({
node: stage,
scaleX: targetZoom,
@@ -35,8 +39,7 @@ export function useCanvasZoom() {
duration: ZOOM_ANIMATION_DURATION,
easing: Konva.Easings.EaseOut,
onFinish: () => {
- setZoom(targetZoom);
- setPanOffset(targetPos);
+ tweenRef.current?.destroy();
tweenRef.current = null;
},
});
diff --git a/apps/web/src/pages/editor-page.tsx b/apps/web/src/pages/editor-page.tsx
index ebf22a8d..ef227a0f 100644
--- a/apps/web/src/pages/editor-page.tsx
+++ b/apps/web/src/pages/editor-page.tsx
@@ -1,7 +1,9 @@
// apps/web/src/pages/editor-page.tsx
import { Monitor } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
+import { CanvasResizeDialog } from "@/components/editor/common/canvas-resize-dialog";
import { ExportDialog, saveEditorState } from "@/components/editor/common/export-dialog";
+import { ImageResizeDialog } from "@/components/editor/common/image-resize-dialog";
import { WelcomeScreen } from "@/components/editor/common/welcome-screen";
import { EditorCanvas } from "@/components/editor/editor-canvas";
import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
@@ -18,6 +20,8 @@ export function EditorPage() {
const isDirty = useEditorStore((s) => s.isDirty);
const loadImage = useEditorStore((s) => s.loadImage);
const [showExport, setShowExport] = useState(false);
+ const [showCanvasResize, setShowCanvasResize] = useState(false);
+ const [showImageResize, setShowImageResize] = useState(false);
// Issue #10: Shortcuts belong at page level, not canvas level
useEditorShortcuts({
@@ -90,13 +94,18 @@ export function EditorPage() {
-
+ setShowCanvasResize(true)}
+ onImageResize={() => setShowImageResize(true)}
+ />
{!sourceImageUrl && }
{showExport && setShowExport(false)} />}
+ setShowCanvasResize(false)} />
+ setShowImageResize(false)} />
);
}
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts
index 537193f8..96f24e94 100644
--- a/apps/web/src/stores/editor-store.ts
+++ b/apps/web/src/stores/editor-store.ts
@@ -9,6 +9,7 @@ import type {
EditorLayer,
EditorState,
FilterConfig,
+ SelectionMode,
ToolType,
} from "@/types/editor";
@@ -79,7 +80,14 @@ function createDefaultLayer(id: string, name: string): EditorLayer {
};
}
-let layerCounter = 1;
+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;
+}
export const useEditorStore = create()(
temporal(
@@ -118,6 +126,8 @@ export const useEditorStore = create()(
// --- Selection ---
selection: null,
+ selectionMode: "new" as SelectionMode,
+ magicWandTolerance: 32,
// --- Crop ---
cropState: null,
@@ -143,6 +153,7 @@ export const useEditorStore = create()(
// --- Clone stamp ---
cloneSource: null,
+ cloneAligned: true,
// --- Dodge/Burn/Sponge ---
dodgeBurnRange: "midtones",
@@ -150,6 +161,18 @@ export const useEditorStore = create()(
spongeMode: "saturate",
spongeFlow: 50,
+ // --- Fill tool ---
+ fillTolerance: 32,
+ fillContiguous: true,
+
+ // --- Gradient tool ---
+ gradientType: "linear",
+ gradientOpacity: 1,
+ gradientReverse: false,
+
+ // --- Pixel brush ---
+ pixelBrushStrength: 50,
+
// --- UI ---
rightPanelTab: "layers",
rightPanelVisible: true,
@@ -184,6 +207,7 @@ export const useEditorStore = create()(
activeTool: tool,
previousTool: activeTool,
isCropping: tool === "crop",
+ ...(activeTool === "crop" && tool !== "crop" ? { cropState: null } : {}),
});
},
@@ -194,27 +218,78 @@ export const useEditorStore = create()(
setPanOffset: (offset) => set({ panOffset: offset }),
loadImage: (url, width, height) => {
+ const oldUrl = get().sourceImageUrl;
+ if (oldUrl?.startsWith("blob:")) {
+ URL.revokeObjectURL(oldUrl);
+ }
set({
sourceImageUrl: url,
sourceImageSize: { width, height },
canvasSize: { width, height },
zoom: 1,
panOffset: { x: 0, y: 0 },
+ 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,
lastAction: "Load Image",
_historyVersion: get()._historyVersion + 1,
});
},
- resizeCanvas: (width, height, _anchor) => {
+ resizeCanvas: (width, height, anchor, _fill) => {
+ 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;
+ }
+ }
set({
canvasSize: { width, height },
+ objects:
+ offsetX !== 0 || offsetY !== 0
+ ? objects.map((obj) => {
+ const attrs = { ...obj.attrs };
+ if ("x" in attrs) {
+ (attrs as { x: number }).x += offsetX;
+ }
+ if ("y" in attrs) {
+ (attrs as { y: number }).y += offsetY;
+ }
+ return { ...obj, attrs } as CanvasObject;
+ })
+ : objects,
isDirty: true,
lastAction: "Resize Canvas",
_historyVersion: get()._historyVersion + 1,
});
},
- resizeImage: (width, height) => {
+ resizeImage: (width, height, _resample) => {
set({
canvasSize: { width, height },
sourceImageSize: { width, height },
@@ -233,22 +308,35 @@ export const useEditorStore = create()(
sourceImageSize: newSize,
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
- if ("x" in attrs && "y" in attrs) {
+ const hasPos = "x" in attrs && "y" in attrs;
+ const hasSize = "width" in attrs && "height" in attrs;
+ const a = attrs as unknown as Record;
+ if (hasPos) {
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;
+ const newX = canvasSize.height - a.y - (hasSize ? a.height : 0);
+ const newY = a.x;
+ a.x = newX;
+ a.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;
+ const newX = a.y;
+ const newY = canvasSize.width - a.x - (hasSize ? a.width : 0);
+ a.x = newX;
+ a.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;
+ a.x = canvasSize.width - a.x - (hasSize ? a.width : 0);
+ a.y = canvasSize.height - a.y - (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;
+ }
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true,
@@ -263,7 +351,9 @@ export const useEditorStore = create()(
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
if ("x" in attrs) {
- (attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x;
+ const a = attrs as unknown as Record;
+ const w = "width" in attrs ? a.width : 0;
+ a.x = canvasSize.width - a.x - w;
}
return { ...obj, attrs } as CanvasObject;
}),
@@ -279,7 +369,9 @@ export const useEditorStore = create()(
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
if ("y" in attrs) {
- (attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y;
+ const a = attrs as unknown as Record;
+ const h = "height" in attrs ? a.height : 0;
+ a.y = canvasSize.height - a.y - h;
}
return { ...obj, attrs } as CanvasObject;
}),
@@ -290,7 +382,50 @@ export const useEditorStore = create()(
},
trimCanvas: () => {
+ 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;
+ 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 : "radiusX" in obj.attrs ? a.radiusX * 2 : 0;
+ const h = "height" in obj.attrs ? a.height : "radiusY" in obj.attrs ? a.radiusY * 2 : 0;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x + w);
+ maxY = Math.max(maxY, y + h);
+ }
+ 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;
set({
+ canvasSize: { width: newWidth, height: newHeight },
+ sourceImageSize: { width: newWidth, height: newHeight },
+ objects: objects.map((obj) => {
+ const attrs = { ...obj.attrs };
+ if ("x" in attrs) {
+ (attrs as unknown as Record).x -= minX;
+ }
+ if ("y" in attrs) {
+ (attrs as unknown as Record).y -= minY;
+ }
+ return { ...obj, attrs } as CanvasObject;
+ }),
isDirty: true,
lastAction: "Trim Canvas",
_historyVersion: get()._historyVersion + 1,
@@ -307,7 +442,14 @@ export const useEditorStore = create()(
set({ foregroundColor: color, recentColors: updated });
},
- setBackgroundColor: (color) => set({ backgroundColor: color }),
+ setBackgroundColor: (color) => {
+ const { recentColors } = get();
+ const updated = [color, ...recentColors.filter((c) => c !== color)].slice(
+ 0,
+ MAX_RECENT_COLORS,
+ );
+ set({ backgroundColor: color, recentColors: updated });
+ },
swapColors: () => {
const { foregroundColor, backgroundColor } = get();
@@ -358,11 +500,17 @@ export const useEditorStore = create()(
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];
+ 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);
set({
- objects: [...otherObjects, ...reordered],
+ objects: newObjects,
lastAction: "Bring to Front",
_historyVersion: get()._historyVersion + 1,
});
@@ -371,9 +519,18 @@ export const useEditorStore = create()(
bringForward: (objectId) => {
const { objects } = get();
const idx = objects.findIndex((o) => o.id === objectId);
- if (idx === -1 || idx === objects.length - 1) return;
+ 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;
const newObjects = [...objects];
- [newObjects[idx], newObjects[idx + 1]] = [newObjects[idx + 1], newObjects[idx]];
+ [newObjects[idx], newObjects[swapIdx]] = [newObjects[swapIdx], newObjects[idx]];
set({
objects: newObjects,
lastAction: "Bring Forward",
@@ -384,9 +541,18 @@ export const useEditorStore = create()(
sendBackward: (objectId) => {
const { objects } = get();
const idx = objects.findIndex((o) => o.id === objectId);
- if (idx <= 0) return;
+ 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;
const newObjects = [...objects];
- [newObjects[idx - 1], newObjects[idx]] = [newObjects[idx], newObjects[idx - 1]];
+ [newObjects[swapIdx], newObjects[idx]] = [newObjects[idx], newObjects[swapIdx]];
set({
objects: newObjects,
lastAction: "Send Backward",
@@ -398,11 +564,17 @@ export const useEditorStore = create()(
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)];
+ const newObjects = objects.filter((o) => o.id !== objectId);
+ let insertIdx = 0;
+ for (let i = 0; i < newObjects.length; i++) {
+ if (newObjects[i].layerId === obj.layerId) {
+ insertIdx = i;
+ break;
+ }
+ }
+ newObjects.splice(insertIdx, 0, obj);
set({
- objects: [...reordered, ...otherObjects],
+ objects: newObjects,
lastAction: "Send to Back",
_historyVersion: get()._historyVersion + 1,
});
@@ -410,9 +582,8 @@ export const useEditorStore = create()(
// Layers
addLayer: () => {
- layerCounter++;
const id = generateId();
- const name = `Layer ${layerCounter}`;
+ const name = `Layer ${nextLayerNumber(get().layers)}`;
const newLayer = createDefaultLayer(id, name);
const { layers, activeLayerId } = get();
const activeIndex = layers.findIndex((l) => l.id === activeLayerId);
@@ -449,7 +620,6 @@ export const useEditorStore = create()(
const source = layers.find((l) => l.id === id);
if (!source) return;
const newId = generateId();
- layerCounter++;
const copy: EditorLayer = {
...source,
id: newId,
@@ -580,6 +750,8 @@ export const useEditorStore = create()(
// Selection
setSelection: (selection) => set({ selection }),
+ setSelectionMode: (mode) => set({ selectionMode: mode }),
+ setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
invertSelection: () => {
const { selection } = get();
@@ -601,6 +773,7 @@ export const useEditorStore = create()(
if (!cropState) return;
set({
canvasSize: { width: cropState.width, height: cropState.height },
+ sourceImageSize: { width: cropState.width, height: cropState.height },
objects: objects.map((obj) => {
const attrs = { ...obj.attrs };
if ("x" in attrs) {
@@ -710,6 +883,41 @@ export const useEditorStore = create()(
markClean: () => set({ isDirty: false }),
setLoadingState: (state) => set({ loadingState: state }),
+ // 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)) }),
+
// Right panel
setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),
diff --git a/apps/web/src/types/editor.ts b/apps/web/src/types/editor.ts
index 4515f7e2..feb83dab 100644
--- a/apps/web/src/types/editor.ts
+++ b/apps/web/src/types/editor.ts
@@ -1,5 +1,7 @@
// apps/web/src/types/editor.ts
+export type SelectionMode = "new" | "add" | "subtract";
+
export type ToolType =
| "move"
| "marquee-rect"
@@ -287,6 +289,8 @@ export interface EditorState {
// Selection
selection: SelectionState | null;
+ selectionMode: SelectionMode;
+ magicWandTolerance: number;
// Crop
cropState: CropState | null;
@@ -309,6 +313,7 @@ export interface EditorState {
// Clone stamp
cloneSource: CloneSource | null;
+ cloneAligned: boolean;
// Dodge/Burn/Sponge
dodgeBurnRange: "shadows" | "midtones" | "highlights";
@@ -316,6 +321,18 @@ export interface EditorState {
spongeMode: "saturate" | "desaturate";
spongeFlow: number;
+ // Fill tool
+ fillTolerance: number;
+ fillContiguous: boolean;
+
+ // Gradient tool
+ gradientType: "linear" | "radial";
+ gradientOpacity: number;
+ gradientReverse: boolean;
+
+ // Pixel brush
+ pixelBrushStrength: number;
+
// UI
rightPanelTab: "layers" | "adjustments" | "history";
rightPanelVisible: boolean;
@@ -352,8 +369,8 @@ export interface EditorState {
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;
+ resizeCanvas: (width: number, height: number, anchor: AnchorPosition, fill?: string) => void;
+ resizeImage: (width: number, height: number, resample?: string) => void;
rotateCanvas: (degrees: 90 | 180 | 270) => void;
flipCanvasHorizontal: () => void;
flipCanvasVertical: () => void;
@@ -393,6 +410,8 @@ export interface EditorState {
// Selection
setSelection: (selection: SelectionState | null) => void;
+ setSelectionMode: (mode: SelectionMode) => void;
+ setMagicWandTolerance: (v: number) => void;
invertSelection: () => void;
// Crop
@@ -424,6 +443,39 @@ export interface EditorState {
markClean: () => void;
setLoadingState: (state: LoadingState | null) => void;
+ // Text
+ setEditingTextId: (id: string | null) => void;
+
+ // Dodge/Burn/Sponge settings
+ setDodgeBurnRange: (range: "shadows" | "midtones" | "highlights") => void;
+ setDodgeBurnExposure: (exposure: number) => void;
+ setSpongeMode: (mode: "saturate" | "desaturate") => void;
+ setSpongeFlow: (flow: number) => void;
+
+ // Shape settings
+ setShapeFill: (fill: string) => void;
+ setShapeStroke: (stroke: string) => void;
+ setShapeStrokeWidth: (width: number) => void;
+ setShapeCornerRadius: (radius: number) => void;
+ setShapePolygonSides: (sides: number) => void;
+ setShapeStarPoints: (points: number) => void;
+
+ // Clone stamp
+ setCloneSource: (source: CloneSource | null) => void;
+ setCloneAligned: (aligned: boolean) => void;
+
+ // Fill tool settings
+ setFillTolerance: (tolerance: number) => void;
+ setFillContiguous: (contiguous: boolean) => void;
+
+ // Gradient tool settings
+ setGradientType: (type: "linear" | "radial") => void;
+ setGradientOpacity: (opacity: number) => void;
+ setGradientReverse: (reverse: boolean) => void;
+
+ // Pixel brush settings
+ setPixelBrushStrength: (strength: number) => void;
+
// Right panel
setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void;
toggleRightPanel: () => void;
diff --git a/tests/unit/web/editor-store.test.ts b/tests/unit/web/editor-store.test.ts
index 7fd51f8a..96f9a1a4 100644
--- a/tests/unit/web/editor-store.test.ts
+++ b/tests/unit/web/editor-store.test.ts
@@ -1168,8 +1168,8 @@ describe("Canvas Transforms", () => {
act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
act((s) => s.rotateCanvas(90));
const obj = state().objects[0];
- // After 90 rotation: newX = canvasHeight - y = 600 - 200, newY = x = 100
- expect(obj.type === "rect" && obj.attrs.x).toBe(400);
+ // After 90 rotation: newX = canvasHeight - y - height = 600 - 200 - 50, newY = x = 100
+ expect(obj.type === "rect" && obj.attrs.x).toBe(350);
expect(obj.type === "rect" && obj.attrs.y).toBe(100);
});
@@ -1178,8 +1178,9 @@ describe("Canvas Transforms", () => {
act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
act((s) => s.flipCanvasHorizontal());
const obj = state().objects[0];
- expect(obj.type === "rect" && obj.attrs.x).toBe(700); // 800 - 100
- expect(obj.type === "rect" && obj.attrs.y).toBe(200); // unchanged
+ // 800 - 100 - width(100) = 600
+ expect(obj.type === "rect" && obj.attrs.x).toBe(600);
+ expect(obj.type === "rect" && obj.attrs.y).toBe(200);
});
it("flipCanvasVertical flips object y positions", () => {
@@ -1187,13 +1188,27 @@ describe("Canvas Transforms", () => {
act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
act((s) => s.flipCanvasVertical());
const obj = state().objects[0];
- expect(obj.type === "rect" && obj.attrs.x).toBe(100); // unchanged
- expect(obj.type === "rect" && obj.attrs.y).toBe(400); // 600 - 200
+ expect(obj.type === "rect" && obj.attrs.x).toBe(100);
+ // 600 - 200 - height(50) = 350
+ expect(obj.type === "rect" && obj.attrs.y).toBe(350);
});
- it("trimCanvas marks dirty", () => {
+ it("trimCanvas trims to object bounds", () => {
+ act((s) => s.loadImage("blob:test", 800, 600));
+ act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
act((s) => s.trimCanvas());
expect(state().isDirty).toBe(true);
+ expect(state().canvasSize).toEqual({ width: 100, height: 50 });
+ const obj = state().objects[0];
+ expect(obj.type === "rect" && obj.attrs.x).toBe(0);
+ expect(obj.type === "rect" && obj.attrs.y).toBe(0);
+ });
+
+ it("trimCanvas no-ops when no objects exist", () => {
+ act((s) => s.loadImage("blob:test", 800, 600));
+ act((s) => s.trimCanvas());
+ expect(state().isDirty).toBe(false);
+ expect(state().canvasSize).toEqual({ width: 800, height: 600 });
});
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 46947e4c..b0597d34 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -34,6 +34,7 @@ export default defineConfig({
exclude: [
"tests/e2e/**",
"tests/e2e-docs/**",
+ "tests/e2e-editor/**",
"tests/e2e-landing/**",
"tests/e2e-docker/**",
"tests/e2e-analytics/**",