mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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/**",
|
||||||
|
|||||||
Reference in New Issue
Block a user