fix: resolve 50+ bugs across editor store, canvas, panels, and tools

- Store: loadImage resets state, applyCrop updates sourceImageSize,
  setTool clears stale cropState, layer ordering preserved across
  layers, rotateCanvas/flipCanvas account for object dimensions,
  trimCanvas implemented, resizeCanvas supports anchor positioning
- Canvas: crop overlay interactive, move tool drag events wired,
  hand tool panning, stage ref effect mount-only, checkerboard
  tracks pan offset, filter cache clearing
- Panels: hslToHex color fix for hue 240-360, adjustments single
  setAdjustment call, curves draggingIndex after sort, history
  panel fresh temporal reads, export transparent setting, canvas
  resize dialog re-sync, hex input respects picker target
- Tools: text tool fixed positioning and event cleanup, shape tool
  deferred addObject, selection mode wired to store, magic wand
  tolerance controlled, eyedropper coordinate transform
- Options: dodge-burn/shape/clone/fill/gradient/pixel-brush use
  proper store actions instead of raw setState
- Added 20+ store actions for tool settings
- Updated tests for corrected rotation/flip/trim behavior
- Excluded e2e-editor from vitest config
This commit is contained in:
SnapOtter
2026-05-07 23:47:08 +08:00
parent d5a03f2a98
commit e99bae428a
39 changed files with 772 additions and 391 deletions
+9 -1
View File
@@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-route
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { ConnectionMonitor } from "./components/common/connection-monitor"; import { ConnectionMonitor } from "./components/common/connection-monitor";
import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider";
import { AppLayout } from "./components/layout/app-layout";
import { useAuth } from "./hooks/use-auth"; import { useAuth } from "./hooks/use-auth";
import { identify, initAnalytics } from "./lib/analytics"; import { identify, initAnalytics } from "./lib/analytics";
import { useAnalyticsStore } from "./stores/analytics-store"; import { useAnalyticsStore } from "./stores/analytics-store";
@@ -225,7 +226,14 @@ export function App() {
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} /> <Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} /> <Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/analytics-consent" element={<AnalyticsConsentPage />} /> <Route path="/analytics-consent" element={<AnalyticsConsentPage />} />
<Route path="/editor" element={<EditorPage />} /> <Route
path="/editor"
element={
<AppLayout showToolPanel={false}>
<EditorPage />
</AppLayout>
}
/>
<Route path="/:toolId" element={<ToolPage />} /> <Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
</Routes> </Routes>
@@ -1,5 +1,5 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { AnchorPosition } from "@/types/editor"; import type { AnchorPosition } from "@/types/editor";
@@ -29,10 +29,17 @@ export function CanvasResizeDialog({ open, onClose }: { open: boolean; onClose:
const [anchor, setAnchor] = useState<AnchorPosition>("center"); const [anchor, setAnchor] = useState<AnchorPosition>("center");
const [fill, setFill] = useState("#ffffff"); const [fill, setFill] = useState("#ffffff");
useEffect(() => {
if (open) {
setWidth(canvasSize.width);
setHeight(canvasSize.height);
}
}, [open, canvasSize]);
const handleApply = useCallback(() => { const handleApply = useCallback(() => {
resizeCanvas(width, height, anchor); resizeCanvas(width, height, anchor, fill);
onClose(); onClose();
}, [width, height, anchor, resizeCanvas, onClose]); }, [width, height, anchor, fill, resizeCanvas, onClose]);
if (!open) return null; if (!open) return null;
@@ -35,7 +35,12 @@ export function ColorSwatch({
label, label,
...dataProps ...dataProps
}: ColorSwatchProps) { }: ColorSwatchProps) {
const isTransparent = color === "transparent" || color.length === 9; const isTransparent =
color.length === 9 ||
color.length === 5 ||
color.toLowerCase().includes("rgba") ||
color.toLowerCase().includes("hsla") ||
color === "transparent";
return ( return (
<button <button
@@ -61,10 +61,14 @@ export function ContextMenu({
position, position,
menuType, menuType,
onClose, onClose,
onCanvasResize,
onImageResize,
}: { }: {
position: MenuPosition; position: MenuPosition;
menuType: "object" | "canvas"; menuType: "object" | "canvas";
onClose: () => void; onClose: () => void;
onCanvasResize?: () => void;
onImageResize?: () => void;
}) { }) {
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -219,7 +223,7 @@ export function ContextMenu({
label: "Canvas Size...", label: "Canvas Size...",
icon: Maximize, icon: Maximize,
action: () => { action: () => {
// Trigger canvas resize dialog (handled by parent) onCanvasResize?.();
onClose(); onClose();
}, },
}, },
@@ -227,7 +231,7 @@ export function ContextMenu({
label: "Image Size...", label: "Image Size...",
icon: ImageIcon, icon: ImageIcon,
action: () => { action: () => {
// Trigger image resize dialog (handled by parent) onImageResize?.();
onClose(); onClose();
}, },
}, },
@@ -134,15 +134,40 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
if (!stage) return; if (!stage) return;
const pixelRatio = settings.width / canvasSize.width; const pixelRatio = settings.width / canvasSize.width;
const dataUrl = stage.toDataURL({ let dataUrl: string;
pixelRatio,
mimeType: getMimeType(settings.format), if (!settings.transparent || settings.format === "jpeg") {
quality: settings.format === "png" ? undefined : settings.quality / 100, // Create canvas with white background for non-transparent exports
x: 0, const stageCanvas = stage.toCanvas({
y: 0, pixelRatio,
width: canvasSize.width, x: 0,
height: canvasSize.height, y: 0,
}); width: canvasSize.width,
height: canvasSize.height,
});
const exportCanvas = document.createElement("canvas");
exportCanvas.width = stageCanvas.width;
exportCanvas.height = stageCanvas.height;
const ctx = exportCanvas.getContext("2d");
if (!ctx) return;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
ctx.drawImage(stageCanvas, 0, 0);
dataUrl = exportCanvas.toDataURL(
`image/${settings.format === "jpeg" ? "jpeg" : "png"}`,
settings.format === "jpeg" ? settings.quality / 100 : undefined,
);
} else {
dataUrl = stage.toDataURL({
pixelRatio,
mimeType: getMimeType(settings.format),
quality: settings.format === "png" ? undefined : settings.quality / 100,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
}
// Convert data URL to blob for download // Convert data URL to blob for download
fetch(dataUrl) fetch(dataUrl)
@@ -157,6 +182,9 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
markClean(); markClean();
})
.catch((err) => {
console.error("Export failed:", err);
}); });
}, [settings, canvasSize, markClean]); }, [settings, canvasSize, markClean]);
@@ -181,8 +209,8 @@ export function ExportDialog({ onClose }: { onClose: () => void }) {
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
setCopyStatus("copied"); setCopyStatus("copied");
setTimeout(() => setCopyStatus("idle"), 2000); setTimeout(() => setCopyStatus("idle"), 2000);
} catch { } catch (err) {
// Clipboard API may not be available in all contexts console.error("Copy to clipboard failed:", err);
} }
}, [settings, canvasSize]); }, [settings, canvasSize]);
@@ -58,9 +58,9 @@ export function ImageResizeDialog({ open, onClose }: { open: boolean; onClose: (
); );
const handleApply = useCallback(() => { const handleApply = useCallback(() => {
resizeImage(width, height); resizeImage(width, height, resample);
onClose(); onClose();
}, [width, height, resizeImage, onClose]); }, [width, height, resample, resizeImage, onClose]);
const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0"; const pctWidth = canvasSize.width > 0 ? ((width / canvasSize.width) * 100).toFixed(1) : "100.0";
const pctHeight = const pctHeight =
@@ -41,21 +41,23 @@ export function NewDocumentDialog({ open, onClose }: NewDocumentDialogProps) {
}; };
const handleCreate = () => { const handleCreate = () => {
const validWidth = Math.max(1, Math.min(10000, width));
const validHeight = Math.max(1, Math.min(10000, height));
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = width; canvas.width = validWidth;
canvas.height = height; canvas.height = validHeight;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (ctx) { if (ctx) {
if (background === "White") { if (background === "White") {
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, validWidth, validHeight);
} else if (background === "Black") { } else if (background === "Black") {
ctx.fillStyle = "#000000"; ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, validWidth, validHeight);
} }
} }
const url = canvas.toDataURL("image/png"); const url = canvas.toDataURL("image/png");
loadImage(url, width, height); loadImage(url, validWidth, validHeight);
onClose(); onClose();
}; };
@@ -22,6 +22,7 @@ import useImage from "use-image";
import { useCanvasZoom } from "@/hooks/use-canvas-zoom"; import { useCanvasZoom } from "@/hooks/use-canvas-zoom";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { AdjustmentValues, CanvasObject, FilterConfig, ImageAttrs } from "@/types/editor"; import type { AdjustmentValues, CanvasObject, FilterConfig, ImageAttrs } from "@/types/editor";
import { ContextMenu, useContextMenu } from "./common/context-menu";
import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor"; import { BrushCursorOverlay, useEditorCursor } from "./common/custom-cursor";
import { LoadingOverlay } from "./common/loading-overlay"; import { LoadingOverlay } from "./common/loading-overlay";
import { useBrushTool } from "./tools/brush-tool"; import { useBrushTool } from "./tools/brush-tool";
@@ -137,6 +138,7 @@ function SourceImage({
} }
} }
node.clearCache();
node.cache(); node.cache();
node.filters(konvaFilters); node.filters(konvaFilters);
node.getLayer()?.batchDraw(); node.getLayer()?.batchDraw();
@@ -159,12 +161,16 @@ function SourceImage({
function ImageObject({ function ImageObject({
obj, obj,
onClick, onClick,
onDragStart,
onDragMove,
onDragEnd, onDragEnd,
onTransformEnd, onTransformEnd,
draggable, draggable,
}: { }: {
obj: CanvasObject & { type: "image" }; obj: CanvasObject & { type: "image" };
onClick?: (e: Konva.KonvaEventObject<MouseEvent>) => void; onClick?: (e: Konva.KonvaEventObject<MouseEvent>) => void;
onDragStart?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onDragMove?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void; onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void; onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
draggable: boolean; draggable: boolean;
@@ -185,6 +191,8 @@ function ImageObject({
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
onClick={onClick} onClick={onClick}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
/> />
@@ -199,12 +207,16 @@ function CanvasObjectRenderer({
obj, obj,
isMoveTool, isMoveTool,
onSelect, onSelect,
onDragStart,
onDragMove,
onDragEnd, onDragEnd,
onTransformEnd, onTransformEnd,
}: { }: {
obj: CanvasObject; obj: CanvasObject;
isMoveTool: boolean; isMoveTool: boolean;
onSelect?: (e: Konva.KonvaEventObject<MouseEvent>) => void; onSelect?: (e: Konva.KonvaEventObject<MouseEvent>) => void;
onDragStart?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onDragMove?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void; onDragEnd?: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void; onTransformEnd?: (e: Konva.KonvaEventObject<Event>) => void;
}) { }) {
@@ -250,6 +262,8 @@ function CanvasObjectRenderer({
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
onClick={onSelect} onClick={onSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
/> />
@@ -271,6 +285,8 @@ function CanvasObjectRenderer({
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
onClick={onSelect} onClick={onSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
/> />
@@ -298,6 +314,8 @@ function CanvasObjectRenderer({
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
onClick={onSelect} onClick={onSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
/> />
@@ -318,6 +336,8 @@ function CanvasObjectRenderer({
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
onClick={onSelect} onClick={onSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
/> />
@@ -339,6 +359,8 @@ function CanvasObjectRenderer({
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
onClick={onSelect} onClick={onSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
/> />
@@ -361,6 +383,8 @@ function CanvasObjectRenderer({
opacity={a.opacity} opacity={a.opacity}
draggable={draggable} draggable={draggable}
onClick={onSelect} onClick={onSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
/> />
@@ -371,6 +395,8 @@ function CanvasObjectRenderer({
<ImageObject <ImageObject
obj={obj} obj={obj}
onClick={onSelect} onClick={onSelect}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
draggable={draggable} draggable={draggable}
@@ -429,7 +455,13 @@ function useActiveToolHandlers(stageRef: React.RefObject<Konva.Stage | null>) {
// Main Canvas Component // Main Canvas Component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function EditorCanvas() { export function EditorCanvas({
onCanvasResize,
onImageResize,
}: {
onCanvasResize?: () => void;
onImageResize?: () => void;
} = {}) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { stageRef, handleWheel, fitToScreen } = useCanvasZoom(); const { stageRef, handleWheel, fitToScreen } = useCanvasZoom();
@@ -443,24 +475,27 @@ export function EditorCanvas() {
const layers = useEditorStore((s) => s.layers); const layers = useEditorStore((s) => s.layers);
const activeLayerId = useEditorStore((s) => s.activeLayerId); const activeLayerId = useEditorStore((s) => s.activeLayerId);
const activeTool = useEditorStore((s) => s.activeTool); const activeTool = useEditorStore((s) => s.activeTool);
const setPanOffset = useEditorStore((s) => s.setPanOffset);
const adjustments = useEditorStore((s) => s.adjustments); const adjustments = useEditorStore((s) => s.adjustments);
const filters = useEditorStore((s) => s.filters); const filters = useEditorStore((s) => s.filters);
// Issue #10: Shortcuts moved to EditorPage, removed from here // Issue #10: Shortcuts moved to EditorPage, removed from here
const cursor = useEditorCursor(); const cursor = useEditorCursor();
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const contextMenu = useContextMenu();
const { handlers, moveTool } = useActiveToolHandlers(stageRef); const { handlers, moveTool } = useActiveToolHandlers(stageRef);
const [stageWidth, setStageWidth] = useState(800); const [stageWidth, setStageWidth] = useState(800);
const [stageHeight, setStageHeight] = useState(600); const [stageHeight, setStageHeight] = useState(600);
// Issue #6: Keep module-level stage ref in sync // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only sync of stable ref
useEffect(() => { useEffect(() => {
editorStageRefHolder.current = stageRef.current; editorStageRefHolder.current = stageRef.current;
return () => { return () => {
editorStageRefHolder.current = null; editorStageRefHolder.current = null;
}; };
}); }, []);
// Issue #5: Track raw screen cursor position for brush overlay // Issue #5: Track raw screen cursor position for brush overlay
const [screenCursor, setScreenCursor] = useState({ x: 0, y: 0 }); const [screenCursor, setScreenCursor] = useState({ x: 0, y: 0 });
@@ -562,9 +597,11 @@ export function EditorCanvas() {
cursor, cursor,
background: sourceImageUrl ? CHECKERBOARD_CSS : undefined, background: sourceImageUrl ? CHECKERBOARD_CSS : undefined,
backgroundSize: sourceImageUrl ? `${checkerboardSize}px ${checkerboardSize}px` : undefined, backgroundSize: sourceImageUrl ? `${checkerboardSize}px ${checkerboardSize}px` : undefined,
backgroundPosition: sourceImageUrl ? `${panOffset.x}px ${panOffset.y}px` : undefined,
}} }}
data-testid="editor-canvas" data-testid="editor-canvas"
onMouseMove={handleContainerMouseMove} onMouseMove={handleContainerMouseMove}
onContextMenu={(e) => contextMenu.handleContextMenu(e, selectedObjectIds.length > 0)}
> >
<Stage <Stage
ref={stageRef} ref={stageRef}
@@ -574,10 +611,19 @@ export function EditorCanvas() {
scaleY={zoom} scaleY={zoom}
x={panOffset.x} x={panOffset.x}
y={panOffset.y} y={panOffset.y}
draggable={activeTool === "hand"}
onWheel={handleWheel} onWheel={handleWheel}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onDragEnd={(e) => {
if (activeTool === "hand") {
const stage = e.target.getStage();
if (stage) {
setPanOffset({ x: stage.x(), y: stage.y() });
}
}
}}
> >
{/* Render objects grouped by layer */} {/* Render objects grouped by layer */}
<Layer> <Layer>
@@ -597,6 +643,8 @@ export function EditorCanvas() {
obj={obj} obj={obj}
isMoveTool={isMoveTool} isMoveTool={isMoveTool}
onSelect={isMoveTool ? moveTool.onSelect : undefined} onSelect={isMoveTool ? moveTool.onSelect : undefined}
onDragStart={isMoveTool ? moveTool.onDragStart : undefined}
onDragMove={isMoveTool ? moveTool.onDragMove : undefined}
onDragEnd={isMoveTool ? moveTool.onDragEnd : undefined} onDragEnd={isMoveTool ? moveTool.onDragEnd : undefined}
onTransformEnd={isMoveTool ? moveTool.onTransformEnd : undefined} onTransformEnd={isMoveTool ? moveTool.onTransformEnd : undefined}
/> />
@@ -626,6 +674,15 @@ export function EditorCanvas() {
</Stage> </Stage>
<BrushCursorOverlay containerRef={containerRef} screenCursor={screenCursor} /> <BrushCursorOverlay containerRef={containerRef} screenCursor={screenCursor} />
<LoadingOverlay /> <LoadingOverlay />
{contextMenu.position && (
<ContextMenu
position={contextMenu.position}
menuType={contextMenu.menuType}
onClose={contextMenu.close}
onCanvasResize={onCanvasResize}
onImageResize={onImageResize}
/>
)}
</div> </div>
); );
} }
@@ -645,7 +702,7 @@ function GridOverlay({
}) { }) {
return ( return (
<Shape <Shape
sceneFunc={(ctx, shape) => { sceneFunc={(ctx, _shape) => {
ctx.beginPath(); ctx.beginPath();
if (showGrid) { if (showGrid) {
@@ -677,8 +734,6 @@ function GridOverlay({
} }
ctx.stroke(); ctx.stroke();
} }
ctx.fillStrokeShape(shape);
}} }}
/> />
); );
@@ -70,8 +70,11 @@ export function EditorOptionsBar() {
return ( return (
<div className="flex items-center h-10 px-3 bg-card border-b border-border gap-3"> <div className="flex items-center h-10 px-3 bg-card border-b border-border gap-3">
<span className="text-xs font-medium text-muted-foreground capitalize"> <span className="text-xs font-medium text-muted-foreground">
{activeTool.replace(/-/g, " ").replace(/^shape /, "")} {activeTool
.replace(/-/g, " ")
.replace(/^shape /, "")
.replace(/\b\w/g, (c) => c.toUpperCase())}
</span> </span>
<div className="h-4 w-px bg-border" /> <div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-2 flex-1">
@@ -28,12 +28,13 @@ export function EditorStatusBar() {
type="number" type="number"
value={zoomPercent} value={zoomPercent}
onChange={(e) => { onChange={(e) => {
const val = Number.parseInt(e.target.value, 10); const val = Number.parseFloat(e.target.value);
if (!Number.isNaN(val) && val > 0) setZoom(val / 100); if (!Number.isNaN(val) && val > 0) setZoom(val / 100);
}} }}
className="w-14 bg-transparent text-right text-xs border-none outline-none" className="w-14 bg-transparent text-right text-xs border-none outline-none"
min={1} min={0.01}
max={6400} max={6400}
step={0.1}
/> />
<span>%</span> <span>%</span>
</div> </div>
@@ -1,8 +1,6 @@
// apps/web/src/components/editor/options/clone-stamp-options.tsx // apps/web/src/components/editor/options/clone-stamp-options.tsx
import { useCallback, useState } from "react";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import { getCloneAligned, setCloneAligned } from "../tools/clone-stamp-tool";
export function CloneStampOptions() { export function CloneStampOptions() {
const activeTool = useEditorStore((s) => s.activeTool); const activeTool = useEditorStore((s) => s.activeTool);
@@ -12,12 +10,8 @@ export function CloneStampOptions() {
const setBrushSize = useEditorStore((s) => s.setBrushSize); const setBrushSize = useEditorStore((s) => s.setBrushSize);
const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity); const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity);
const setBrushHardness = useEditorStore((s) => s.setBrushHardness); const setBrushHardness = useEditorStore((s) => s.setBrushHardness);
const [aligned, setLocalAligned] = useState(getCloneAligned); const cloneAligned = useEditorStore((s) => s.cloneAligned);
const setCloneAligned = useEditorStore((s) => s.setCloneAligned);
const handleAlignedChange = useCallback((value: boolean) => {
setCloneAligned(value);
setLocalAligned(value);
}, []);
if (activeTool !== "clone-stamp") return null; if (activeTool !== "clone-stamp") return null;
@@ -92,8 +86,8 @@ export function CloneStampOptions() {
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={aligned} checked={cloneAligned}
onChange={(e) => handleAlignedChange(e.target.checked)} onChange={(e) => setCloneAligned(e.target.checked)}
className="accent-primary" className="accent-primary"
/> />
Aligned Aligned
@@ -14,6 +14,10 @@ export function DodgeBurnOptions() {
const dodgeBurnExposure = useEditorStore((s) => s.dodgeBurnExposure); const dodgeBurnExposure = useEditorStore((s) => s.dodgeBurnExposure);
const spongeMode = useEditorStore((s) => s.spongeMode); const spongeMode = useEditorStore((s) => s.spongeMode);
const spongeFlow = useEditorStore((s) => s.spongeFlow); const spongeFlow = useEditorStore((s) => s.spongeFlow);
const setDodgeBurnRange = useEditorStore((s) => s.setDodgeBurnRange);
const setDodgeBurnExposure = useEditorStore((s) => s.setDodgeBurnExposure);
const setSpongeMode = useEditorStore((s) => s.setSpongeMode);
const setSpongeFlow = useEditorStore((s) => s.setSpongeFlow);
if (!DODGE_BURN_TOOLS.has(activeTool)) return null; if (!DODGE_BURN_TOOLS.has(activeTool)) return null;
@@ -63,9 +67,7 @@ export function DodgeBurnOptions() {
<select <select
value={dodgeBurnRange} value={dodgeBurnRange}
onChange={(e) => onChange={(e) =>
useEditorStore.setState({ setDodgeBurnRange(e.target.value as "shadows" | "midtones" | "highlights")
dodgeBurnRange: e.target.value as "shadows" | "midtones" | "highlights",
})
} }
className="h-6 text-xs bg-muted border border-border rounded px-1" className="h-6 text-xs bg-muted border border-border rounded px-1"
> >
@@ -85,11 +87,7 @@ export function DodgeBurnOptions() {
min={1} min={1}
max={100} max={100}
value={dodgeBurnExposure} value={dodgeBurnExposure}
onChange={(e) => onChange={(e) => setDodgeBurnExposure(Number(e.target.value))}
useEditorStore.setState({
dodgeBurnExposure: Number(e.target.value),
})
}
className="w-16 h-1 accent-primary" className="w-16 h-1 accent-primary"
/> />
<input <input
@@ -97,11 +95,7 @@ export function DodgeBurnOptions() {
min={1} min={1}
max={100} max={100}
value={dodgeBurnExposure} value={dodgeBurnExposure}
onChange={(e) => onChange={(e) => setDodgeBurnExposure(Number(e.target.value))}
useEditorStore.setState({
dodgeBurnExposure: Number(e.target.value),
})
}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
<span className="text-[10px]">%</span> <span className="text-[10px]">%</span>
@@ -114,11 +108,7 @@ export function DodgeBurnOptions() {
Mode Mode
<select <select
value={spongeMode} value={spongeMode}
onChange={(e) => onChange={(e) => setSpongeMode(e.target.value as "saturate" | "desaturate")}
useEditorStore.setState({
spongeMode: e.target.value as "saturate" | "desaturate",
})
}
className="h-6 text-xs bg-muted border border-border rounded px-1" className="h-6 text-xs bg-muted border border-border rounded px-1"
> >
<option value="saturate">Saturate</option> <option value="saturate">Saturate</option>
@@ -136,11 +126,7 @@ export function DodgeBurnOptions() {
min={1} min={1}
max={100} max={100}
value={spongeFlow} value={spongeFlow}
onChange={(e) => onChange={(e) => setSpongeFlow(Number(e.target.value))}
useEditorStore.setState({
spongeFlow: Number(e.target.value),
})
}
className="w-16 h-1 accent-primary" className="w-16 h-1 accent-primary"
/> />
<input <input
@@ -148,11 +134,7 @@ export function DodgeBurnOptions() {
min={1} min={1}
max={100} max={100}
value={spongeFlow} value={spongeFlow}
onChange={(e) => onChange={(e) => setSpongeFlow(Number(e.target.value))}
useEditorStore.setState({
spongeFlow: Number(e.target.value),
})
}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
<span className="text-[10px]">%</span> <span className="text-[10px]">%</span>
@@ -1,28 +1,13 @@
// apps/web/src/components/editor/options/fill-options.tsx // apps/web/src/components/editor/options/fill-options.tsx
import { useCallback, useState } from "react";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import {
getFillContiguous,
getFillTolerance,
setFillContiguous,
setFillTolerance,
} from "../tools/fill-tool";
export function FillOptions() { export function FillOptions() {
const activeTool = useEditorStore((s) => s.activeTool); const activeTool = useEditorStore((s) => s.activeTool);
const [tolerance, setLocalTolerance] = useState(getFillTolerance); const tolerance = useEditorStore((s) => s.fillTolerance);
const [contiguous, setLocalContiguous] = useState(getFillContiguous); const contiguous = useEditorStore((s) => s.fillContiguous);
const setFillTolerance = useEditorStore((s) => s.setFillTolerance);
const handleToleranceChange = useCallback((value: number) => { const setFillContiguous = useEditorStore((s) => s.setFillContiguous);
setFillTolerance(value);
setLocalTolerance(value);
}, []);
const handleContiguousChange = useCallback((value: boolean) => {
setFillContiguous(value);
setLocalContiguous(value);
}, []);
if (activeTool !== "fill") return null; if (activeTool !== "fill") return null;
@@ -36,7 +21,7 @@ export function FillOptions() {
min={0} min={0}
max={255} max={255}
value={tolerance} value={tolerance}
onChange={(e) => handleToleranceChange(Number(e.target.value))} onChange={(e) => setFillTolerance(Number(e.target.value))}
className="w-20 h-1 accent-primary" className="w-20 h-1 accent-primary"
/> />
<input <input
@@ -44,7 +29,7 @@ export function FillOptions() {
min={0} min={0}
max={255} max={255}
value={tolerance} value={tolerance}
onChange={(e) => handleToleranceChange(Number(e.target.value))} onChange={(e) => setFillTolerance(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
</label> </label>
@@ -54,7 +39,7 @@ export function FillOptions() {
<input <input
type="checkbox" type="checkbox"
checked={contiguous} checked={contiguous}
onChange={(e) => handleContiguousChange(e.target.checked)} onChange={(e) => setFillContiguous(e.target.checked)}
className="accent-primary" className="accent-primary"
/> />
Contiguous Contiguous
@@ -1,48 +1,28 @@
// apps/web/src/components/editor/options/gradient-options.tsx // apps/web/src/components/editor/options/gradient-options.tsx
import { useCallback, useState } from "react";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import {
type GradientType,
getGradientOpacity,
getGradientReverse,
getGradientType,
setGradientOpacity,
setGradientReverse,
setGradientType,
} from "../tools/gradient-tool";
export function GradientOptions() { export function GradientOptions() {
const activeTool = useEditorStore((s) => s.activeTool); const activeTool = useEditorStore((s) => s.activeTool);
const [type, setLocalType] = useState<GradientType>(getGradientType); const gradientType = useEditorStore((s) => s.gradientType);
const [opacity, setLocalOpacity] = useState(() => Math.round(getGradientOpacity() * 100)); const gradientOpacity = useEditorStore((s) => s.gradientOpacity);
const [reverse, setLocalReverse] = useState(getGradientReverse); const gradientReverse = useEditorStore((s) => s.gradientReverse);
const setGradientType = useEditorStore((s) => s.setGradientType);
const handleTypeChange = useCallback((value: GradientType) => { const setGradientOpacity = useEditorStore((s) => s.setGradientOpacity);
setGradientType(value); const setGradientReverse = useEditorStore((s) => s.setGradientReverse);
setLocalType(value);
}, []);
const handleOpacityChange = useCallback((value: number) => {
setGradientOpacity(value / 100);
setLocalOpacity(value);
}, []);
const handleReverseChange = useCallback((value: boolean) => {
setGradientReverse(value);
setLocalReverse(value);
}, []);
if (activeTool !== "gradient") return null; if (activeTool !== "gradient") return null;
const opacityPercent = Math.round(gradientOpacity * 100);
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Type toggle */} {/* Type toggle */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Type Type
<select <select
value={type} value={gradientType}
onChange={(e) => handleTypeChange(e.target.value as GradientType)} onChange={(e) => setGradientType(e.target.value as "linear" | "radial")}
className="h-6 text-xs bg-muted border border-border rounded px-1" className="h-6 text-xs bg-muted border border-border rounded px-1"
> >
<option value="linear">Linear</option> <option value="linear">Linear</option>
@@ -57,16 +37,16 @@ export function GradientOptions() {
type="range" type="range"
min={0} min={0}
max={100} max={100}
value={opacity} value={opacityPercent}
onChange={(e) => handleOpacityChange(Number(e.target.value))} onChange={(e) => setGradientOpacity(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary" className="w-20 h-1 accent-primary"
/> />
<input <input
type="number" type="number"
min={0} min={0}
max={100} max={100}
value={opacity} value={opacityPercent}
onChange={(e) => handleOpacityChange(Number(e.target.value))} onChange={(e) => setGradientOpacity(Number(e.target.value) / 100)}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
<span className="text-[10px]">%</span> <span className="text-[10px]">%</span>
@@ -76,8 +56,8 @@ export function GradientOptions() {
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={reverse} checked={gradientReverse}
onChange={(e) => handleReverseChange(e.target.checked)} onChange={(e) => setGradientReverse(e.target.checked)}
className="accent-primary" className="accent-primary"
/> />
Reverse Reverse
@@ -50,8 +50,9 @@ export function MoveOptions() {
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const objects = useEditorStore((s) => s.objects); const objects = useEditorStore((s) => s.objects);
const updateObject = useEditorStore((s) => s.updateObject); const updateObject = useEditorStore((s) => s.updateObject);
const canvasSize = useEditorStore((s) => s.canvasSize);
const hasSelection = selectedObjectIds.length >= 2; const hasSelection = selectedObjectIds.length >= 1;
const hasThreeOrMore = selectedObjectIds.length >= 3; const hasThreeOrMore = selectedObjectIds.length >= 3;
const handleAlign = ( const handleAlign = (
@@ -70,6 +71,7 @@ export function MoveOptions() {
selectedObjectIds, selectedObjectIds,
objects.map((o) => ({ id: o.id, attrs: o.attrs as unknown as Record<string, unknown> })), objects.map((o) => ({ id: o.id, attrs: o.attrs as unknown as Record<string, unknown> })),
updateObject, updateObject,
canvasSize,
); );
}; };
@@ -1,9 +1,7 @@
// apps/web/src/components/editor/options/pixel-brush-options.tsx // apps/web/src/components/editor/options/pixel-brush-options.tsx
import { useCallback, useState } from "react";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor"; import type { ToolType } from "@/types/editor";
import { getPixelBrushStrength, setPixelBrushStrength } from "../tools/pixel-brush-tool";
const PIXEL_BRUSH_TOOLS = new Set<ToolType>(["blur-brush", "sharpen-brush", "smudge"]); const PIXEL_BRUSH_TOOLS = new Set<ToolType>(["blur-brush", "sharpen-brush", "smudge"]);
@@ -12,12 +10,8 @@ export function PixelBrushOptions() {
const setTool = useEditorStore((s) => s.setTool); const setTool = useEditorStore((s) => s.setTool);
const brushSize = useEditorStore((s) => s.brushSize); const brushSize = useEditorStore((s) => s.brushSize);
const setBrushSize = useEditorStore((s) => s.setBrushSize); const setBrushSize = useEditorStore((s) => s.setBrushSize);
const [strength, setLocalStrength] = useState(getPixelBrushStrength); const strength = useEditorStore((s) => s.pixelBrushStrength);
const setPixelBrushStrength = useEditorStore((s) => s.setPixelBrushStrength);
const handleStrengthChange = useCallback((value: number) => {
setPixelBrushStrength(value);
setLocalStrength(value);
}, []);
if (!PIXEL_BRUSH_TOOLS.has(activeTool)) return null; if (!PIXEL_BRUSH_TOOLS.has(activeTool)) return null;
@@ -66,7 +60,7 @@ export function PixelBrushOptions() {
min={1} min={1}
max={100} max={100}
value={strength} value={strength}
onChange={(e) => handleStrengthChange(Number(e.target.value))} onChange={(e) => setPixelBrushStrength(Number(e.target.value))}
className="w-20 h-1 accent-primary" className="w-20 h-1 accent-primary"
/> />
<input <input
@@ -74,7 +68,7 @@ export function PixelBrushOptions() {
min={1} min={1}
max={100} max={100}
value={strength} value={strength}
onChange={(e) => handleStrengthChange(Number(e.target.value))} onChange={(e) => setPixelBrushStrength(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
<span className="text-[10px]">%</span> <span className="text-[10px]">%</span>
@@ -1,10 +1,9 @@
import { Circle, Minus, PenTool, Plus, Square, Wand2 } from "lucide-react"; import { Circle, Minus, PenTool, Plus, Square, Wand2 } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor"; import type { SelectionMode, ToolType } from "@/types/editor";
type SelectionMode = "new" | "add" | "subtract";
type SelectionType = "rect" | "ellipse" | "lasso"; type SelectionType = "rect" | "ellipse" | "lasso";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -45,7 +44,10 @@ function ToggleButton({
export function SelectionOptions() { export function SelectionOptions() {
const activeTool = useEditorStore((s) => s.activeTool); const activeTool = useEditorStore((s) => s.activeTool);
const setTool = useEditorStore((s) => s.setTool); const setTool = useEditorStore((s) => s.setTool);
const [selectionMode, setSelectionMode] = useState<SelectionMode>("new"); const selectionMode = useEditorStore((s) => s.selectionMode);
const setSelectionMode = useEditorStore((s) => s.setSelectionMode);
const magicWandTolerance = useEditorStore((s) => s.magicWandTolerance);
const setMagicWandTolerance = useEditorStore((s) => s.setMagicWandTolerance);
const selectionType: SelectionType = const selectionType: SelectionType =
activeTool === "marquee-ellipse" activeTool === "marquee-ellipse"
@@ -66,9 +68,12 @@ export function SelectionOptions() {
[setTool], [setTool],
); );
const handleModeChange = useCallback((mode: SelectionMode) => { const handleModeChange = useCallback(
setSelectionMode(mode); (mode: SelectionMode) => {
}, []); setSelectionMode(mode);
},
[setSelectionMode],
);
const isMarquee = activeTool === "marquee-rect" || activeTool === "marquee-ellipse"; const isMarquee = activeTool === "marquee-rect" || activeTool === "marquee-ellipse";
const isLasso = activeTool === "lasso-free" || activeTool === "lasso-poly"; const isLasso = activeTool === "lasso-free" || activeTool === "lasso-poly";
@@ -176,7 +181,8 @@ export function SelectionOptions() {
type="number" type="number"
min={0} min={0}
max={255} max={255}
defaultValue={32} value={magicWandTolerance}
onChange={(e) => setMagicWandTolerance(Number(e.target.value))}
className={cn( className={cn(
"h-6 w-14 rounded border border-border bg-card px-1.5 text-xs text-foreground", "h-6 w-14 rounded border border-border bg-card px-1.5 text-xs text-foreground",
"focus:border-primary focus:outline-none", "focus:border-primary focus:outline-none",
@@ -30,6 +30,12 @@ export function ShapeOptions() {
const shapeCornerRadius = useEditorStore((s) => s.shapeCornerRadius); const shapeCornerRadius = useEditorStore((s) => s.shapeCornerRadius);
const shapePolygonSides = useEditorStore((s) => s.shapePolygonSides); const shapePolygonSides = useEditorStore((s) => s.shapePolygonSides);
const shapeStarPoints = useEditorStore((s) => s.shapeStarPoints); const shapeStarPoints = useEditorStore((s) => s.shapeStarPoints);
const setShapeFill = useEditorStore((s) => s.setShapeFill);
const setShapeStroke = useEditorStore((s) => s.setShapeStroke);
const setShapeStrokeWidth = useEditorStore((s) => s.setShapeStrokeWidth);
const setShapeCornerRadius = useEditorStore((s) => s.setShapeCornerRadius);
const setShapePolygonSides = useEditorStore((s) => s.setShapePolygonSides);
const setShapeStarPoints = useEditorStore((s) => s.setShapeStarPoints);
if (!SHAPE_TOOLS.has(activeTool)) return null; if (!SHAPE_TOOLS.has(activeTool)) return null;
@@ -57,7 +63,7 @@ export function ShapeOptions() {
<input <input
type="color" type="color"
value={shapeFill} value={shapeFill}
onChange={(e) => useEditorStore.setState({ shapeFill: e.target.value })} onChange={(e) => setShapeFill(e.target.value)}
className="w-6 h-6 border border-border rounded cursor-pointer" className="w-6 h-6 border border-border rounded cursor-pointer"
/> />
</label> </label>
@@ -68,7 +74,7 @@ export function ShapeOptions() {
<input <input
type="color" type="color"
value={shapeStroke} value={shapeStroke}
onChange={(e) => useEditorStore.setState({ shapeStroke: e.target.value })} onChange={(e) => setShapeStroke(e.target.value)}
className="w-6 h-6 border border-border rounded cursor-pointer" className="w-6 h-6 border border-border rounded cursor-pointer"
/> />
</label> </label>
@@ -81,11 +87,7 @@ export function ShapeOptions() {
min={0} min={0}
max={50} max={50}
value={shapeStrokeWidth} value={shapeStrokeWidth}
onChange={(e) => onChange={(e) => setShapeStrokeWidth(Number(e.target.value))}
useEditorStore.setState({
shapeStrokeWidth: Number(e.target.value),
})
}
className="w-16 h-1 accent-primary" className="w-16 h-1 accent-primary"
/> />
<input <input
@@ -93,11 +95,7 @@ export function ShapeOptions() {
min={0} min={0}
max={50} max={50}
value={shapeStrokeWidth} value={shapeStrokeWidth}
onChange={(e) => onChange={(e) => setShapeStrokeWidth(Number(e.target.value))}
useEditorStore.setState({
shapeStrokeWidth: Number(e.target.value),
})
}
className="w-10 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-10 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
</label> </label>
@@ -111,11 +109,7 @@ export function ShapeOptions() {
min={0} min={0}
max={100} max={100}
value={shapeCornerRadius} value={shapeCornerRadius}
onChange={(e) => onChange={(e) => setShapeCornerRadius(Number(e.target.value))}
useEditorStore.setState({
shapeCornerRadius: Number(e.target.value),
})
}
className="w-16 h-1 accent-primary" className="w-16 h-1 accent-primary"
/> />
<input <input
@@ -123,11 +117,7 @@ export function ShapeOptions() {
min={0} min={0}
max={100} max={100}
value={shapeCornerRadius} value={shapeCornerRadius}
onChange={(e) => onChange={(e) => setShapeCornerRadius(Number(e.target.value))}
useEditorStore.setState({
shapeCornerRadius: Number(e.target.value),
})
}
className="w-10 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-10 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
</label> </label>
@@ -142,11 +132,7 @@ export function ShapeOptions() {
min={3} min={3}
max={20} max={20}
value={shapePolygonSides} value={shapePolygonSides}
onChange={(e) => onChange={(e) => setShapePolygonSides(Number(e.target.value))}
useEditorStore.setState({
shapePolygonSides: Number(e.target.value),
})
}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
</label> </label>
@@ -161,11 +147,7 @@ export function ShapeOptions() {
min={3} min={3}
max={20} max={20}
value={shapeStarPoints} value={shapeStarPoints}
onChange={(e) => onChange={(e) => setShapeStarPoints(Number(e.target.value))}
useEditorStore.setState({
shapeStarPoints: Number(e.target.value),
})
}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1" className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/> />
</label> </label>
@@ -16,6 +16,27 @@ import { useEditorStore } from "@/stores/editor-store";
import type { TextAttrs } from "@/types/editor"; import type { TextAttrs } from "@/types/editor";
import { getAllFonts, isSystemFont, loadGoogleFont } from "../common/font-loader"; 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 // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -213,14 +234,14 @@ export function TextOptions() {
const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds); const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
const objects = useEditorStore((s) => s.objects); const objects = useEditorStore((s) => s.objects);
// Derive current attrs from the selected text object // Derive current attrs from the selected text object, fall back to defaults
const attrs = useMemo(() => { const selectedAttrs = useMemo(() => {
if (selectedObjectIds.length !== 1) return null; if (selectedObjectIds.length !== 1) return null;
const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text"); const obj = objects.find((o) => o.id === selectedObjectIds[0] && o.type === "text");
return obj ? (obj.attrs as TextAttrs) : null; return obj ? (obj.attrs as TextAttrs) : null;
}, [selectedObjectIds, objects]); }, [selectedObjectIds, objects]);
if (!attrs) return null; const attrs = selectedAttrs ?? DEFAULT_TEXT_ATTRS;
const isBold = attrs.fontStyle.includes("bold"); const isBold = attrs.fontStyle.includes("bold");
const isItalic = attrs.fontStyle.includes("italic"); const isItalic = attrs.fontStyle.includes("italic");
@@ -252,7 +273,7 @@ export function TextOptions() {
const had = current.textDecoration.includes("underline"); const had = current.textDecoration.includes("underline");
const parts = current.textDecoration.split(" ").filter((p) => p !== "underline" && p !== ""); const parts = current.textDecoration.split(" ").filter((p) => p !== "underline" && p !== "");
if (!had) parts.push("underline"); if (!had) parts.push("underline");
updateSelected({ textDecoration: parts.join(" ") }); updateSelected({ textDecoration: parts.join(" ") || "none" });
}; };
const toggleStrikethrough = () => { const toggleStrikethrough = () => {
@@ -261,7 +282,7 @@ export function TextOptions() {
const had = current.textDecoration.includes("line-through"); const had = current.textDecoration.includes("line-through");
const parts = current.textDecoration.split(" ").filter((p) => p !== "line-through" && p !== ""); const parts = current.textDecoration.split(" ").filter((p) => p !== "line-through" && p !== "");
if (!had) parts.push("line-through"); if (!had) parts.push("line-through");
updateSelected({ textDecoration: parts.join(" ") }); updateSelected({ textDecoration: parts.join(" ") || "none" });
}; };
const toggleTextMode = () => { const toggleTextMode = () => {
@@ -12,8 +12,6 @@ import type { AdjustmentValues } from "@/types/editor";
// Constants // Constants
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const DEBOUNCE_MS = 150;
const ADJUSTMENT_SLIDERS: { const ADJUSTMENT_SLIDERS: {
key: keyof AdjustmentValues; key: keyof AdjustmentValues;
label: string; label: string;
@@ -378,28 +376,13 @@ function AutoAdjustmentsSection() {
function AdjustmentsSlidersSection() { function AdjustmentsSlidersSection() {
const adjustments = useEditorStore((s) => s.adjustments); const adjustments = useEditorStore((s) => s.adjustments);
const setAdjustment = useEditorStore((s) => s.setAdjustment); const setAdjustment = useEditorStore((s) => s.setAdjustment);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const handleChange = useCallback( const handleChange = useCallback(
(key: keyof AdjustmentValues, value: number) => { (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(key, value);
}, },
[setAdjustment], [setAdjustment],
); );
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
return ( return (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{ADJUSTMENT_SLIDERS.map(({ key, label, min, max }) => ( {ADJUSTMENT_SLIDERS.map(({ key, label, min, max }) => (
@@ -778,6 +761,8 @@ function CurvesSection() {
const pts = [...prev[channel]]; const pts = [...prev[channel]];
pts[draggingIndex] = pos; pts[draggingIndex] = pos;
pts.sort((a, b) => a.x - b.x); 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 }; return { ...prev, [channel]: pts };
}); });
}, },
@@ -1058,15 +1043,24 @@ export function AdjustmentsPanel() {
}, [adjustments, filters]); }, [adjustments, filters]);
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
resetAdjustments();
// Reset all filters by toggling off any enabled ones
const store = useEditorStore.getState(); const store = useEditorStore.getState();
for (const f of store.filters) { useEditorStore.setState({
if (f.enabled) { adjustments: {
store.toggleFilter(f.type); brightness: 0,
} contrast: 0,
} hue: 0,
}, [resetAdjustments]); 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(() => { const handleApply = useCallback(() => {
// Bake adjustments and filters into pixel data // Bake adjustments and filters into pixel data
@@ -74,11 +74,13 @@ function hslToHex(h: number, s: number, l: number): string {
gn = x; gn = x;
bn = c; bn = c;
} else if (h < 300) { } else if (h < 300) {
rn = c;
bn = x;
} else {
rn = x; rn = x;
gn = 0;
bn = c; bn = c;
} else {
rn = c;
gn = 0;
bn = x;
} }
const r = Math.round((rn + m) * 255); const r = Math.round((rn + m) * 255);
@@ -305,10 +307,11 @@ export function ColorPanel() {
const [pickerTarget, setPickerTarget] = useState<ColorTarget | null>(null); const [pickerTarget, setPickerTarget] = useState<ColorTarget | null>(null);
const [hexInput, setHexInput] = useState(foregroundColor); 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(() => { useEffect(() => {
setHexInput(foregroundColor); const color = pickerTarget === "bg" ? backgroundColor : foregroundColor;
}, [foregroundColor]); setHexInput(color);
}, [foregroundColor, backgroundColor, pickerTarget]);
const activeColor = pickerTarget === "bg" ? backgroundColor : foregroundColor; const activeColor = pickerTarget === "bg" ? backgroundColor : foregroundColor;
const setActiveColor = pickerTarget === "bg" ? setBackgroundColor : setForegroundColor; const setActiveColor = pickerTarget === "bg" ? setBackgroundColor : setForegroundColor;
@@ -333,16 +336,17 @@ export function ColorPanel() {
setHexInput(v); setHexInput(v);
if (!v.startsWith("#")) v = `#${v}`; if (!v.startsWith("#")) v = `#${v}`;
if (isValidHex(v)) { if (isValidHex(v)) {
setForegroundColor(v.toLowerCase()); setActiveColor(v.toLowerCase());
} }
}, },
[setForegroundColor], [setActiveColor],
); );
const handleHexInputBlur = useCallback(() => { const handleHexInputBlur = useCallback(() => {
// Reset to current foreground if invalid // Reset to current active color if invalid
setHexInput(foregroundColor); const color = pickerTarget === "bg" ? backgroundColor : foregroundColor;
}, [foregroundColor]); setHexInput(color);
}, [foregroundColor, backgroundColor, pickerTarget]);
return ( return (
<div className="p-3" data-testid="color-panel"> <div className="p-3" data-testid="color-panel">
@@ -81,9 +81,6 @@ interface HistoryEntry {
export function HistoryPanel() { export function HistoryPanel() {
const lastAction = useEditorStore((s) => s.lastAction); 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 // Force re-render when history changes by subscribing to history version
useEditorStore((s) => s._historyVersion); useEditorStore((s) => s._historyVersion);
@@ -154,10 +151,10 @@ export function HistoryPanel() {
<button <button
type="button" type="button"
onClick={undo} onClick={undo}
disabled={pastStates.length === 0} disabled={useEditorStore.temporal.getState().pastStates.length === 0}
className={cn( className={cn(
"p-1 rounded transition-colors", "p-1 rounded transition-colors",
pastStates.length > 0 useEditorStore.temporal.getState().pastStates.length > 0
? "text-muted-foreground hover:text-foreground hover:bg-muted" ? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed", : "text-muted-foreground/30 cursor-not-allowed",
)} )}
@@ -169,10 +166,10 @@ export function HistoryPanel() {
<button <button
type="button" type="button"
onClick={redo} onClick={redo}
disabled={futureStates.length === 0} disabled={useEditorStore.temporal.getState().futureStates.length === 0}
className={cn( className={cn(
"p-1 rounded transition-colors", "p-1 rounded transition-colors",
futureStates.length > 0 useEditorStore.temporal.getState().futureStates.length > 0
? "text-muted-foreground hover:text-foreground hover:bg-muted" ? "text-muted-foreground hover:text-foreground hover:bg-muted"
: "text-muted-foreground/30 cursor-not-allowed", : "text-muted-foreground/30 cursor-not-allowed",
)} )}
@@ -181,7 +178,9 @@ export function HistoryPanel() {
> >
<Redo2 size={14} /> <Redo2 size={14} />
</button> </button>
<span className="ml-auto text-[10px] text-muted-foreground">{pastStates.length} / 50</span> <span className="ml-auto text-[10px] text-muted-foreground">
{useEditorStore.temporal.getState().pastStates.length} / 50
</span>
</div> </div>
{/* History list */} {/* History list */}
@@ -196,8 +196,8 @@ export function NavigatorPanel() {
const handleZoomSlider = useCallback( const handleZoomSlider = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number.parseFloat(e.target.value); const t = Number.parseFloat(e.target.value);
setZoom(val); setZoom(MIN_ZOOM * (MAX_ZOOM / MIN_ZOOM) ** t);
}, },
[setZoom], [setZoom],
); );
@@ -252,10 +252,10 @@ export function NavigatorPanel() {
</button> </button>
<input <input
type="range" type="range"
min={MIN_ZOOM} min={0}
max={MAX_ZOOM} max={1}
step={0.01} step={0.001}
value={zoom} value={Math.log(zoom / MIN_ZOOM) / Math.log(MAX_ZOOM / MIN_ZOOM)}
onChange={handleZoomSlider} onChange={handleZoomSlider}
className={cn( className={cn(
"flex-1 h-1 appearance-none rounded-full bg-muted", "flex-1 h-1 appearance-none rounded-full bg-muted",
@@ -6,16 +6,6 @@ import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor"; import type { CanvasObject } from "@/types/editor";
let cloneAligned = true;
export function setCloneAligned(value: boolean) {
cloneAligned = value;
}
export function getCloneAligned(): boolean {
return cloneAligned;
}
interface StampState { interface StampState {
objectId: string; objectId: string;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@@ -30,11 +20,20 @@ interface StampState {
export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>) { export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>) {
const stampRef = useRef<StampState | null>(null); const stampRef = useRef<StampState | null>(null);
const initialOffsetRef = useRef<{ x: number; y: number } | null>(null); const initialOffsetRef = useRef<{ x: number; y: number } | null>(null);
const setCloneSource = useEditorStore((s) => s.setCloneSource);
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => { (e: Konva.KonvaEventObject<MouseEvent>) => {
const { activeTool, cloneSource, brushSize, brushOpacity, canvasSize, zoom, panOffset } = const {
useEditorStore.getState(); activeTool,
cloneSource,
cloneAligned,
brushSize,
brushOpacity,
canvasSize,
zoom,
panOffset,
} = useEditorStore.getState();
if (activeTool !== "clone-stamp") return; if (activeTool !== "clone-stamp") return;
@@ -49,9 +48,7 @@ export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>)
// Alt+click sets the clone source // Alt+click sets the clone source
if (e.evt.altKey) { if (e.evt.altKey) {
useEditorStore.setState({ setCloneSource({ x, y, aligned: cloneAligned });
cloneSource: { x, y, aligned: cloneAligned },
});
initialOffsetRef.current = null; initialOffsetRef.current = null;
return; return;
} }
@@ -127,7 +124,7 @@ export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>)
offsetY, offsetY,
}; };
}, },
[stageRef], [stageRef, setCloneSource],
); );
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => { const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
@@ -188,7 +188,7 @@ export function CropOverlay() {
}; };
return ( return (
<Group listening={false}> <Group>
{/* Darkened overlays: top, bottom, left, right */} {/* Darkened overlays: top, bottom, left, right */}
<Rect x={0} y={0} width={cw} height={y} fill={overlayFill} listening={false} /> <Rect x={0} y={0} width={cw} height={y} fill={overlayFill} listening={false} />
<Rect <Rect
@@ -52,6 +52,8 @@ interface UseEyedropperToolOptions {
export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOptions) { export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOptions) {
const setForegroundColor = useEditorStore((s) => s.setForegroundColor); const setForegroundColor = useEditorStore((s) => s.setForegroundColor);
const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor); const setBackgroundColor = useEditorStore((s) => s.setBackgroundColor);
const zoom = useEditorStore((s) => s.zoom);
const panOffset = useEditorStore((s) => s.panOffset);
const [sampledColor, setSampledColor] = useState<string | null>(null); const [sampledColor, setSampledColor] = useState<string | null>(null);
const canvasCache = useRef<HTMLCanvasElement | null>(null); const canvasCache = useRef<HTMLCanvasElement | null>(null);
@@ -96,9 +98,9 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) return null; if (!ctx) return null;
// pointer coordinates are already in stage pixel space // Transform pointer coordinates from screen space to canvas space
const x = Math.round(pointer.x); const x = Math.round((pointer.x - panOffset.x) / zoom);
const y = Math.round(pointer.y); const y = Math.round((pointer.y - panOffset.y) / zoom);
// Bounds check // Bounds check
if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) { if (x < 0 || y < 0 || x >= canvas.width || y >= canvas.height) {
@@ -109,7 +111,7 @@ export function useEyedropperTool({ stageRef, sampleSize }: UseEyedropperToolOpt
setSampledColor(color); setSampledColor(color);
return color; return color;
}, },
[getStageCanvas, sampleSize], [getStageCanvas, sampleSize, zoom, panOffset],
); );
/** /**
@@ -6,26 +6,6 @@ import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor"; 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( function colorDistance(
r1: number, r1: number,
g1: number, g1: number,
@@ -157,8 +137,15 @@ function floodFill(
export function useFillTool(stageRef: React.RefObject<Konva.Stage | null>) { export function useFillTool(stageRef: React.RefObject<Konva.Stage | null>) {
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(_e: Konva.KonvaEventObject<MouseEvent>) => { (_e: Konva.KonvaEventObject<MouseEvent>) => {
const { activeTool, foregroundColor, canvasSize, zoom, panOffset } = const {
useEditorStore.getState(); activeTool,
foregroundColor,
fillTolerance,
fillContiguous,
canvasSize,
zoom,
panOffset,
} = useEditorStore.getState();
if (activeTool !== "fill") return; if (activeTool !== "fill") return;
@@ -6,36 +6,6 @@ import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor"; 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 { interface DragState {
startX: number; startX: number;
startY: number; startY: number;
@@ -68,8 +38,16 @@ export function useGradientTool() {
const handleMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => { const handleMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!dragRef.current) return; if (!dragRef.current) return;
const { foregroundColor, backgroundColor, canvasSize, zoom, panOffset } = const {
useEditorStore.getState(); foregroundColor,
backgroundColor,
gradientType,
gradientOpacity,
gradientReverse,
canvasSize,
zoom,
panOffset,
} = useEditorStore.getState();
const stage = e.target.getStage(); const stage = e.target.getStage();
if (!stage) return; if (!stage) return;
@@ -157,9 +157,10 @@ export function alignObjects(
objectIds: string[], objectIds: string[],
objects: { id: string; attrs: Record<string, unknown> }[], objects: { id: string; attrs: Record<string, unknown> }[],
updateObject: (id: string, attrs: Record<string, unknown>) => void, updateObject: (id: string, attrs: Record<string, unknown>) => void,
canvasSize?: { width: number; height: number },
): void { ): void {
const selected = objects.filter((o) => objectIds.includes(o.id)); 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; if (selected.length < 3 && direction.startsWith("distribute")) return;
const bounds = selected.map((o) => ({ const bounds = selected.map((o) => ({
@@ -170,6 +171,34 @@ export function alignObjects(
h: (o.attrs.height as number) ?? 0, 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) { switch (direction) {
case "left": { case "left": {
const minX = Math.min(...bounds.map((b) => b.x)); const minX = Math.min(...bounds.map((b) => b.x));
@@ -8,16 +8,6 @@ import type { CanvasObject, ToolType } from "@/types/editor";
const PIXEL_BRUSH_TOOLS = new Set<ToolType>(["blur-brush", "sharpen-brush", "smudge"]); const PIXEL_BRUSH_TOOLS = new Set<ToolType>(["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 { interface StrokeState {
objectId: string; objectId: string;
canvas: HTMLCanvasElement; canvas: HTMLCanvasElement;
@@ -141,7 +131,7 @@ function applyPixelBrush(
centerY: number, centerY: number,
canvasSize: { width: number; height: number }, canvasSize: { width: number; height: number },
): void { ): void {
const { activeTool, brushSize } = useEditorStore.getState(); const { activeTool, brushSize, pixelBrushStrength } = useEditorStore.getState();
const strength = pixelBrushStrength / 100; const strength = pixelBrushStrength / 100;
const halfSize = Math.floor(brushSize / 2); const halfSize = Math.floor(brushSize / 2);
@@ -2,9 +2,8 @@ import type Konva from "konva";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Ellipse, Group, Line, Rect } from "react-konva"; import { Ellipse, Group, Line, Rect } from "react-konva";
import { useEditorStore } from "@/stores/editor-store"; 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"; type SelectionType = "rect" | "ellipse" | "lasso";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -240,7 +239,7 @@ export function useSelectionTool(): SelectionToolApi {
const [currentPoints, setCurrentPoints] = useState<number[]>([]); const [currentPoints, setCurrentPoints] = useState<number[]>([]);
const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const startRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const [selectionMode] = useState<SelectionMode>("new"); const selectionMode = useEditorStore((s) => s.selectionMode);
const setSelection = useEditorStore((s) => s.setSelection); const setSelection = useEditorStore((s) => s.setSelection);
const canvasSize = useEditorStore((s) => s.canvasSize); const canvasSize = useEditorStore((s) => s.canvasSize);
@@ -6,6 +6,13 @@ import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, ToolType } from "@/types/editor"; import type { CanvasObject, ToolType } from "@/types/editor";
interface PendingShape {
startX: number;
startY: number;
toolType: ToolType;
obj: CanvasObject;
}
interface DragState { interface DragState {
startX: number; startX: number;
startY: number; startY: number;
@@ -41,6 +48,9 @@ function constrainToDimension(
export function useShapeTool() { export function useShapeTool() {
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const pendingRef = useRef<PendingShape | null>(null);
const MIN_DRAG_THRESHOLD = 2;
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => { const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage(); const stage = e.target.getStage();
@@ -183,11 +193,34 @@ export function useShapeTool() {
return; return;
} }
useEditorStore.getState().addObject(obj); // Don't add the object yet -- wait until the user drags past the threshold
dragRef.current = { startX: x, startY: y, objectId: id, toolType: activeTool }; pendingRef.current = { startX: x, startY: y, toolType: activeTool, obj };
}, []); }, []);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => { const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
// 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; if (!dragRef.current) return;
const stage = e.target.getStage(); const stage = e.target.getStage();
@@ -280,6 +313,11 @@ export function useShapeTool() {
}, []); }, []);
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
// Click without drag -- pending shape was never added, just discard it
if (pendingRef.current) {
pendingRef.current = null;
}
if (!dragRef.current) return; if (!dragRef.current) return;
const { objectId } = dragRef.current; const { objectId } = dragRef.current;
@@ -287,7 +325,7 @@ export function useShapeTool() {
const obj = objects.find((o) => o.id === objectId); const obj = objects.find((o) => o.id === objectId);
if (obj) { if (obj) {
// Remove zero-size shapes // Remove degenerate shapes that are still too small
const attrs = obj.attrs; const attrs = obj.attrs;
let isDegenerate = false; let isDegenerate = false;
if ("width" in attrs && "height" in attrs) { if ("width" in attrs && "height" in attrs) {
@@ -14,6 +14,7 @@ import type { CanvasObject, TextAttrs } from "@/types/editor";
*/ */
export function useTextTool() { export function useTextTool() {
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
const pendingRef = useRef<{ const pendingRef = useRef<{
objectId: string; objectId: string;
x: number; x: number;
@@ -23,6 +24,8 @@ export function useTextTool() {
// Clean up any lingering textarea on unmount // Clean up any lingering textarea on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
abortRef.current?.abort();
abortRef.current = null;
textareaRef.current?.remove(); textareaRef.current?.remove();
textareaRef.current = null; textareaRef.current = null;
}; };
@@ -34,6 +37,8 @@ export function useTextTool() {
if (!textarea || !pending) return; if (!textarea || !pending) return;
const text = textarea.value.trim(); const text = textarea.value.trim();
abortRef.current?.abort();
abortRef.current = null;
textarea.remove(); textarea.remove();
textareaRef.current = null; textareaRef.current = null;
pendingRef.current = null; pendingRef.current = null;
@@ -114,7 +119,7 @@ export function useTextTool() {
const containerRect = container.getBoundingClientRect(); const containerRect = container.getBoundingClientRect();
const textarea = document.createElement("textarea"); const textarea = document.createElement("textarea");
textarea.style.position = "absolute"; textarea.style.position = "fixed";
textarea.style.left = `${pointer.x + containerRect.left}px`; textarea.style.left = `${pointer.x + containerRect.left}px`;
textarea.style.top = `${pointer.y + containerRect.top}px`; textarea.style.top = `${pointer.y + containerRect.top}px`;
textarea.style.fontSize = `${fontSize * zoom}px`; textarea.style.fontSize = `${fontSize * zoom}px`;
@@ -138,6 +143,10 @@ export function useTextTool() {
document.body.appendChild(textarea); document.body.appendChild(textarea);
textareaRef.current = textarea; textareaRef.current = textarea;
const ac = new AbortController();
abortRef.current = ac;
const { signal } = ac;
// Auto-resize as user types // Auto-resize as user types
const autoResize = () => { const autoResize = () => {
textarea.style.height = "auto"; textarea.style.height = "auto";
@@ -146,24 +155,32 @@ export function useTextTool() {
textarea.style.width = `${Math.max(60, textarea.scrollWidth + 4)}px`; textarea.style.width = `${Math.max(60, textarea.scrollWidth + 4)}px`;
}; };
textarea.addEventListener("input", autoResize); textarea.addEventListener("input", autoResize, { signal });
textarea.addEventListener("blur", () => { textarea.addEventListener(
commitText(); "blur",
}); () => {
commitText();
},
{ signal },
);
textarea.addEventListener("keydown", (ke) => { textarea.addEventListener(
// Enter without Shift commits; Shift+Enter inserts newline "keydown",
if (ke.key === "Enter" && !ke.shiftKey) { (ke) => {
ke.preventDefault(); // Enter without Shift commits; Shift+Enter inserts newline
textarea.blur(); if (ke.key === "Enter" && !ke.shiftKey) {
} ke.preventDefault();
if (ke.key === "Escape") { textarea.blur();
ke.preventDefault(); }
textarea.value = ""; if (ke.key === "Escape") {
textarea.blur(); ke.preventDefault();
} textarea.value = "";
}); textarea.blur();
}
},
{ signal },
);
// Focus after a microtask so the click doesn't immediately blur // Focus after a microtask so the click doesn't immediately blur
requestAnimationFrame(() => textarea.focus()); requestAnimationFrame(() => textarea.focus());
+5 -2
View File
@@ -26,6 +26,10 @@ export function useCanvasZoom() {
if (tweenRef.current) { if (tweenRef.current) {
tweenRef.current.destroy(); tweenRef.current.destroy();
} }
// Update store immediately so all tools get correct coordinates
setZoom(targetZoom);
setPanOffset(targetPos);
tweenRef.current = new Konva.Tween({ tweenRef.current = new Konva.Tween({
node: stage, node: stage,
scaleX: targetZoom, scaleX: targetZoom,
@@ -35,8 +39,7 @@ export function useCanvasZoom() {
duration: ZOOM_ANIMATION_DURATION, duration: ZOOM_ANIMATION_DURATION,
easing: Konva.Easings.EaseOut, easing: Konva.Easings.EaseOut,
onFinish: () => { onFinish: () => {
setZoom(targetZoom); tweenRef.current?.destroy();
setPanOffset(targetPos);
tweenRef.current = null; tweenRef.current = null;
}, },
}); });
+10 -1
View File
@@ -1,7 +1,9 @@
// apps/web/src/pages/editor-page.tsx // apps/web/src/pages/editor-page.tsx
import { Monitor } from "lucide-react"; import { Monitor } from "lucide-react";
import { useCallback, useEffect, useState } from "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 { 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 { WelcomeScreen } from "@/components/editor/common/welcome-screen";
import { EditorCanvas } from "@/components/editor/editor-canvas"; import { EditorCanvas } from "@/components/editor/editor-canvas";
import { EditorOptionsBar } from "@/components/editor/editor-options-bar"; import { EditorOptionsBar } from "@/components/editor/editor-options-bar";
@@ -18,6 +20,8 @@ export function EditorPage() {
const isDirty = useEditorStore((s) => s.isDirty); const isDirty = useEditorStore((s) => s.isDirty);
const loadImage = useEditorStore((s) => s.loadImage); const loadImage = useEditorStore((s) => s.loadImage);
const [showExport, setShowExport] = useState(false); 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 // Issue #10: Shortcuts belong at page level, not canvas level
useEditorShortcuts({ useEditorShortcuts({
@@ -90,13 +94,18 @@ export function EditorPage() {
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<EditorToolbar /> <EditorToolbar />
<div className="relative flex-1 overflow-hidden bg-muted/30"> <div className="relative flex-1 overflow-hidden bg-muted/30">
<EditorCanvas /> <EditorCanvas
onCanvasResize={() => setShowCanvasResize(true)}
onImageResize={() => setShowImageResize(true)}
/>
{!sourceImageUrl && <WelcomeScreen />} {!sourceImageUrl && <WelcomeScreen />}
</div> </div>
<EditorRightPanel /> <EditorRightPanel />
</div> </div>
<EditorStatusBar /> <EditorStatusBar />
{showExport && <ExportDialog onClose={() => setShowExport(false)} />} {showExport && <ExportDialog onClose={() => setShowExport(false)} />}
<CanvasResizeDialog open={showCanvasResize} onClose={() => setShowCanvasResize(false)} />
<ImageResizeDialog open={showImageResize} onClose={() => setShowImageResize(false)} />
</div> </div>
); );
} }
+240 -32
View File
@@ -9,6 +9,7 @@ import type {
EditorLayer, EditorLayer,
EditorState, EditorState,
FilterConfig, FilterConfig,
SelectionMode,
ToolType, ToolType,
} from "@/types/editor"; } 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<EditorState>()( export const useEditorStore = create<EditorState>()(
temporal( temporal(
@@ -118,6 +126,8 @@ export const useEditorStore = create<EditorState>()(
// --- Selection --- // --- Selection ---
selection: null, selection: null,
selectionMode: "new" as SelectionMode,
magicWandTolerance: 32,
// --- Crop --- // --- Crop ---
cropState: null, cropState: null,
@@ -143,6 +153,7 @@ export const useEditorStore = create<EditorState>()(
// --- Clone stamp --- // --- Clone stamp ---
cloneSource: null, cloneSource: null,
cloneAligned: true,
// --- Dodge/Burn/Sponge --- // --- Dodge/Burn/Sponge ---
dodgeBurnRange: "midtones", dodgeBurnRange: "midtones",
@@ -150,6 +161,18 @@ export const useEditorStore = create<EditorState>()(
spongeMode: "saturate", spongeMode: "saturate",
spongeFlow: 50, spongeFlow: 50,
// --- Fill tool ---
fillTolerance: 32,
fillContiguous: true,
// --- Gradient tool ---
gradientType: "linear",
gradientOpacity: 1,
gradientReverse: false,
// --- Pixel brush ---
pixelBrushStrength: 50,
// --- UI --- // --- UI ---
rightPanelTab: "layers", rightPanelTab: "layers",
rightPanelVisible: true, rightPanelVisible: true,
@@ -184,6 +207,7 @@ export const useEditorStore = create<EditorState>()(
activeTool: tool, activeTool: tool,
previousTool: activeTool, previousTool: activeTool,
isCropping: tool === "crop", isCropping: tool === "crop",
...(activeTool === "crop" && tool !== "crop" ? { cropState: null } : {}),
}); });
}, },
@@ -194,27 +218,78 @@ export const useEditorStore = create<EditorState>()(
setPanOffset: (offset) => set({ panOffset: offset }), setPanOffset: (offset) => set({ panOffset: offset }),
loadImage: (url, width, height) => { loadImage: (url, width, height) => {
const oldUrl = get().sourceImageUrl;
if (oldUrl?.startsWith("blob:")) {
URL.revokeObjectURL(oldUrl);
}
set({ set({
sourceImageUrl: url, sourceImageUrl: url,
sourceImageSize: { width, height }, sourceImageSize: { width, height },
canvasSize: { width, height }, canvasSize: { width, height },
zoom: 1, zoom: 1,
panOffset: { x: 0, y: 0 }, 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", lastAction: "Load Image",
_historyVersion: get()._historyVersion + 1, _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({ set({
canvasSize: { width, height }, 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, isDirty: true,
lastAction: "Resize Canvas", lastAction: "Resize Canvas",
_historyVersion: get()._historyVersion + 1, _historyVersion: get()._historyVersion + 1,
}); });
}, },
resizeImage: (width, height) => { resizeImage: (width, height, _resample) => {
set({ set({
canvasSize: { width, height }, canvasSize: { width, height },
sourceImageSize: { width, height }, sourceImageSize: { width, height },
@@ -233,22 +308,35 @@ export const useEditorStore = create<EditorState>()(
sourceImageSize: newSize, sourceImageSize: newSize,
objects: objects.map((obj) => { objects: objects.map((obj) => {
const attrs = { ...obj.attrs }; 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<string, number>;
if (hasPos) {
if (degrees === 90) { if (degrees === 90) {
const newX = canvasSize.height - (attrs as { y: number }).y; const newX = canvasSize.height - a.y - (hasSize ? a.height : 0);
const newY = (attrs as { x: number }).x; const newY = a.x;
(attrs as { x: number }).x = newX; a.x = newX;
(attrs as { y: number }).y = newY; a.y = newY;
} else if (degrees === 270) { } else if (degrees === 270) {
const newX = (attrs as { y: number }).y; const newX = a.y;
const newY = canvasSize.width - (attrs as { x: number }).x; const newY = canvasSize.width - a.x - (hasSize ? a.width : 0);
(attrs as { x: number }).x = newX; a.x = newX;
(attrs as { y: number }).y = newY; a.y = newY;
} else { } else {
(attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x; a.x = canvasSize.width - a.x - (hasSize ? a.width : 0);
(attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y; 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; return { ...obj, attrs } as CanvasObject;
}), }),
isDirty: true, isDirty: true,
@@ -263,7 +351,9 @@ export const useEditorStore = create<EditorState>()(
objects: objects.map((obj) => { objects: objects.map((obj) => {
const attrs = { ...obj.attrs }; const attrs = { ...obj.attrs };
if ("x" in attrs) { if ("x" in attrs) {
(attrs as { x: number }).x = canvasSize.width - (attrs as { x: number }).x; const a = attrs as unknown as Record<string, number>;
const w = "width" in attrs ? a.width : 0;
a.x = canvasSize.width - a.x - w;
} }
return { ...obj, attrs } as CanvasObject; return { ...obj, attrs } as CanvasObject;
}), }),
@@ -279,7 +369,9 @@ export const useEditorStore = create<EditorState>()(
objects: objects.map((obj) => { objects: objects.map((obj) => {
const attrs = { ...obj.attrs }; const attrs = { ...obj.attrs };
if ("y" in attrs) { if ("y" in attrs) {
(attrs as { y: number }).y = canvasSize.height - (attrs as { y: number }).y; const a = attrs as unknown as Record<string, number>;
const h = "height" in attrs ? a.height : 0;
a.y = canvasSize.height - a.y - h;
} }
return { ...obj, attrs } as CanvasObject; return { ...obj, attrs } as CanvasObject;
}), }),
@@ -290,7 +382,50 @@ export const useEditorStore = create<EditorState>()(
}, },
trimCanvas: () => { 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<string, number>;
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({ 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<string, number>).x -= minX;
}
if ("y" in attrs) {
(attrs as unknown as Record<string, number>).y -= minY;
}
return { ...obj, attrs } as CanvasObject;
}),
isDirty: true, isDirty: true,
lastAction: "Trim Canvas", lastAction: "Trim Canvas",
_historyVersion: get()._historyVersion + 1, _historyVersion: get()._historyVersion + 1,
@@ -307,7 +442,14 @@ export const useEditorStore = create<EditorState>()(
set({ foregroundColor: color, recentColors: updated }); 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: () => { swapColors: () => {
const { foregroundColor, backgroundColor } = get(); const { foregroundColor, backgroundColor } = get();
@@ -358,11 +500,17 @@ export const useEditorStore = create<EditorState>()(
const { objects } = get(); const { objects } = get();
const obj = objects.find((o) => o.id === objectId); const obj = objects.find((o) => o.id === objectId);
if (!obj) return; if (!obj) return;
const layerObjects = objects.filter((o) => o.layerId === obj.layerId); const newObjects = objects.filter((o) => o.id !== objectId);
const otherObjects = objects.filter((o) => o.layerId !== obj.layerId); let insertIdx = newObjects.length;
const reordered = [...layerObjects.filter((o) => o.id !== objectId), obj]; 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({ set({
objects: [...otherObjects, ...reordered], objects: newObjects,
lastAction: "Bring to Front", lastAction: "Bring to Front",
_historyVersion: get()._historyVersion + 1, _historyVersion: get()._historyVersion + 1,
}); });
@@ -371,9 +519,18 @@ export const useEditorStore = create<EditorState>()(
bringForward: (objectId) => { bringForward: (objectId) => {
const { objects } = get(); const { objects } = get();
const idx = objects.findIndex((o) => o.id === objectId); 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]; const newObjects = [...objects];
[newObjects[idx], newObjects[idx + 1]] = [newObjects[idx + 1], newObjects[idx]]; [newObjects[idx], newObjects[swapIdx]] = [newObjects[swapIdx], newObjects[idx]];
set({ set({
objects: newObjects, objects: newObjects,
lastAction: "Bring Forward", lastAction: "Bring Forward",
@@ -384,9 +541,18 @@ export const useEditorStore = create<EditorState>()(
sendBackward: (objectId) => { sendBackward: (objectId) => {
const { objects } = get(); const { objects } = get();
const idx = objects.findIndex((o) => o.id === objectId); 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]; const newObjects = [...objects];
[newObjects[idx - 1], newObjects[idx]] = [newObjects[idx], newObjects[idx - 1]]; [newObjects[swapIdx], newObjects[idx]] = [newObjects[idx], newObjects[swapIdx]];
set({ set({
objects: newObjects, objects: newObjects,
lastAction: "Send Backward", lastAction: "Send Backward",
@@ -398,11 +564,17 @@ export const useEditorStore = create<EditorState>()(
const { objects } = get(); const { objects } = get();
const obj = objects.find((o) => o.id === objectId); const obj = objects.find((o) => o.id === objectId);
if (!obj) return; if (!obj) return;
const layerObjects = objects.filter((o) => o.layerId === obj.layerId); const newObjects = objects.filter((o) => o.id !== objectId);
const otherObjects = objects.filter((o) => o.layerId !== obj.layerId); let insertIdx = 0;
const reordered = [obj, ...layerObjects.filter((o) => o.id !== objectId)]; for (let i = 0; i < newObjects.length; i++) {
if (newObjects[i].layerId === obj.layerId) {
insertIdx = i;
break;
}
}
newObjects.splice(insertIdx, 0, obj);
set({ set({
objects: [...reordered, ...otherObjects], objects: newObjects,
lastAction: "Send to Back", lastAction: "Send to Back",
_historyVersion: get()._historyVersion + 1, _historyVersion: get()._historyVersion + 1,
}); });
@@ -410,9 +582,8 @@ export const useEditorStore = create<EditorState>()(
// Layers // Layers
addLayer: () => { addLayer: () => {
layerCounter++;
const id = generateId(); const id = generateId();
const name = `Layer ${layerCounter}`; const name = `Layer ${nextLayerNumber(get().layers)}`;
const newLayer = createDefaultLayer(id, name); const newLayer = createDefaultLayer(id, name);
const { layers, activeLayerId } = get(); const { layers, activeLayerId } = get();
const activeIndex = layers.findIndex((l) => l.id === activeLayerId); const activeIndex = layers.findIndex((l) => l.id === activeLayerId);
@@ -449,7 +620,6 @@ export const useEditorStore = create<EditorState>()(
const source = layers.find((l) => l.id === id); const source = layers.find((l) => l.id === id);
if (!source) return; if (!source) return;
const newId = generateId(); const newId = generateId();
layerCounter++;
const copy: EditorLayer = { const copy: EditorLayer = {
...source, ...source,
id: newId, id: newId,
@@ -580,6 +750,8 @@ export const useEditorStore = create<EditorState>()(
// Selection // Selection
setSelection: (selection) => set({ selection }), setSelection: (selection) => set({ selection }),
setSelectionMode: (mode) => set({ selectionMode: mode }),
setMagicWandTolerance: (v) => set({ magicWandTolerance: v }),
invertSelection: () => { invertSelection: () => {
const { selection } = get(); const { selection } = get();
@@ -601,6 +773,7 @@ export const useEditorStore = create<EditorState>()(
if (!cropState) return; if (!cropState) return;
set({ set({
canvasSize: { width: cropState.width, height: cropState.height }, canvasSize: { width: cropState.width, height: cropState.height },
sourceImageSize: { width: cropState.width, height: cropState.height },
objects: objects.map((obj) => { objects: objects.map((obj) => {
const attrs = { ...obj.attrs }; const attrs = { ...obj.attrs };
if ("x" in attrs) { if ("x" in attrs) {
@@ -710,6 +883,41 @@ export const useEditorStore = create<EditorState>()(
markClean: () => set({ isDirty: false }), markClean: () => set({ isDirty: false }),
setLoadingState: (state) => set({ loadingState: state }), 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 // Right panel
setRightPanelTab: (tab) => set({ rightPanelTab: tab }), setRightPanelTab: (tab) => set({ rightPanelTab: tab }),
toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }), toggleRightPanel: () => set({ rightPanelVisible: !get().rightPanelVisible }),
+54 -2
View File
@@ -1,5 +1,7 @@
// apps/web/src/types/editor.ts // apps/web/src/types/editor.ts
export type SelectionMode = "new" | "add" | "subtract";
export type ToolType = export type ToolType =
| "move" | "move"
| "marquee-rect" | "marquee-rect"
@@ -287,6 +289,8 @@ export interface EditorState {
// Selection // Selection
selection: SelectionState | null; selection: SelectionState | null;
selectionMode: SelectionMode;
magicWandTolerance: number;
// Crop // Crop
cropState: CropState | null; cropState: CropState | null;
@@ -309,6 +313,7 @@ export interface EditorState {
// Clone stamp // Clone stamp
cloneSource: CloneSource | null; cloneSource: CloneSource | null;
cloneAligned: boolean;
// Dodge/Burn/Sponge // Dodge/Burn/Sponge
dodgeBurnRange: "shadows" | "midtones" | "highlights"; dodgeBurnRange: "shadows" | "midtones" | "highlights";
@@ -316,6 +321,18 @@ export interface EditorState {
spongeMode: "saturate" | "desaturate"; spongeMode: "saturate" | "desaturate";
spongeFlow: number; spongeFlow: number;
// Fill tool
fillTolerance: number;
fillContiguous: boolean;
// Gradient tool
gradientType: "linear" | "radial";
gradientOpacity: number;
gradientReverse: boolean;
// Pixel brush
pixelBrushStrength: number;
// UI // UI
rightPanelTab: "layers" | "adjustments" | "history"; rightPanelTab: "layers" | "adjustments" | "history";
rightPanelVisible: boolean; rightPanelVisible: boolean;
@@ -352,8 +369,8 @@ export interface EditorState {
setZoom: (zoom: number) => void; setZoom: (zoom: number) => void;
setPanOffset: (offset: { x: number; y: number }) => void; setPanOffset: (offset: { x: number; y: number }) => void;
loadImage: (url: string, width: number, height: number) => void; loadImage: (url: string, width: number, height: number) => void;
resizeCanvas: (width: number, height: number, anchor: AnchorPosition) => void; resizeCanvas: (width: number, height: number, anchor: AnchorPosition, fill?: string) => void;
resizeImage: (width: number, height: number) => void; resizeImage: (width: number, height: number, resample?: string) => void;
rotateCanvas: (degrees: 90 | 180 | 270) => void; rotateCanvas: (degrees: 90 | 180 | 270) => void;
flipCanvasHorizontal: () => void; flipCanvasHorizontal: () => void;
flipCanvasVertical: () => void; flipCanvasVertical: () => void;
@@ -393,6 +410,8 @@ export interface EditorState {
// Selection // Selection
setSelection: (selection: SelectionState | null) => void; setSelection: (selection: SelectionState | null) => void;
setSelectionMode: (mode: SelectionMode) => void;
setMagicWandTolerance: (v: number) => void;
invertSelection: () => void; invertSelection: () => void;
// Crop // Crop
@@ -424,6 +443,39 @@ export interface EditorState {
markClean: () => void; markClean: () => void;
setLoadingState: (state: LoadingState | null) => 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 // Right panel
setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void; setRightPanelTab: (tab: "layers" | "adjustments" | "history") => void;
toggleRightPanel: () => void; toggleRightPanel: () => void;
+22 -7
View File
@@ -1168,8 +1168,8 @@ describe("Canvas Transforms", () => {
act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 }))); act((s) => s.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
act((s) => s.rotateCanvas(90)); act((s) => s.rotateCanvas(90));
const obj = state().objects[0]; const obj = state().objects[0];
// After 90 rotation: newX = canvasHeight - y = 600 - 200, newY = x = 100 // After 90 rotation: newX = canvasHeight - y - height = 600 - 200 - 50, newY = x = 100
expect(obj.type === "rect" && obj.attrs.x).toBe(400); expect(obj.type === "rect" && obj.attrs.x).toBe(350);
expect(obj.type === "rect" && obj.attrs.y).toBe(100); 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.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
act((s) => s.flipCanvasHorizontal()); act((s) => s.flipCanvasHorizontal());
const obj = state().objects[0]; const obj = state().objects[0];
expect(obj.type === "rect" && obj.attrs.x).toBe(700); // 800 - 100 // 800 - 100 - width(100) = 600
expect(obj.type === "rect" && obj.attrs.y).toBe(200); // unchanged expect(obj.type === "rect" && obj.attrs.x).toBe(600);
expect(obj.type === "rect" && obj.attrs.y).toBe(200);
}); });
it("flipCanvasVertical flips object y positions", () => { 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.addObject(makeRect({ id: "r1", x: 100, y: 200 })));
act((s) => s.flipCanvasVertical()); act((s) => s.flipCanvasVertical());
const obj = state().objects[0]; const obj = state().objects[0];
expect(obj.type === "rect" && obj.attrs.x).toBe(100); // unchanged expect(obj.type === "rect" && obj.attrs.x).toBe(100);
expect(obj.type === "rect" && obj.attrs.y).toBe(400); // 600 - 200 // 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()); act((s) => s.trimCanvas());
expect(state().isDirty).toBe(true); 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 });
}); });
}); });
+1
View File
@@ -34,6 +34,7 @@ export default defineConfig({
exclude: [ exclude: [
"tests/e2e/**", "tests/e2e/**",
"tests/e2e-docs/**", "tests/e2e-docs/**",
"tests/e2e-editor/**",
"tests/e2e-landing/**", "tests/e2e-landing/**",
"tests/e2e-docker/**", "tests/e2e-docker/**",
"tests/e2e-analytics/**", "tests/e2e-analytics/**",