Merge branch 'worktree-agent-ac1b61d3' into feat/image-editor

This commit is contained in:
SnapOtter
2026-05-06 23:24:55 +08:00
16 changed files with 2671 additions and 0 deletions
@@ -0,0 +1,217 @@
// apps/web/src/components/editor/common/fill-dialog.tsx
import { useCallback, useEffect, useState } from "react";
import { cn, generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
type FillContent = "foreground" | "background" | "color" | "white" | "black" | "50gray";
interface FillDialogProps {
open: boolean;
onClose: () => void;
}
const FILL_PRESETS: { value: FillContent; label: string }[] = [
{ value: "foreground", label: "Foreground Color" },
{ value: "background", label: "Background Color" },
{ value: "color", label: "Color..." },
{ value: "white", label: "White" },
{ value: "black", label: "Black" },
{ value: "50gray", label: "50% Gray" },
];
function resolveColor(
content: FillContent,
customColor: string,
foreground: string,
background: string,
): string {
switch (content) {
case "foreground":
return foreground;
case "background":
return background;
case "color":
return customColor;
case "white":
return "#ffffff";
case "black":
return "#000000";
case "50gray":
return "#808080";
}
}
export function FillDialog({ open, onClose }: FillDialogProps) {
const [content, setContent] = useState<FillContent>("foreground");
const [customColor, setCustomColor] = useState("#ff0000");
const [opacity, setOpacity] = useState(100);
const foregroundColor = useEditorStore((s) => s.foregroundColor);
const backgroundColor = useEditorStore((s) => s.backgroundColor);
const canvasSize = useEditorStore((s) => s.canvasSize);
const activeLayerId = useEditorStore((s) => s.activeLayerId);
const addObject = useEditorStore((s) => s.addObject);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
const handleFill = useCallback(() => {
const fillColor = resolveColor(content, customColor, foregroundColor, backgroundColor);
// Create a canvas with the solid fill
const canvas = document.createElement("canvas");
canvas.width = canvasSize.width;
canvas.height = canvasSize.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.globalAlpha = opacity / 100;
ctx.fillStyle = fillColor;
ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
const dataUrl = canvas.toDataURL();
const obj: CanvasObject = {
id: generateId(),
type: "image",
layerId: activeLayerId,
attrs: {
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
rotation: 0,
opacity: 1,
src: dataUrl,
},
};
addObject(obj);
onClose();
}, [
content,
customColor,
foregroundColor,
backgroundColor,
canvasSize,
activeLayerId,
opacity,
addObject,
onClose,
]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
className="w-80 rounded-lg bg-card border border-border shadow-xl p-4"
role="dialog"
aria-label="Fill"
>
<h3 className="text-sm font-semibold text-foreground mb-3">Fill</h3>
{/* Content selector */}
<div className="mb-3">
<span className="text-xs text-muted-foreground mb-1 block">Contents</span>
<select
value={content}
onChange={(e) => setContent(e.target.value as FillContent)}
className="w-full h-8 text-sm bg-muted border border-border rounded px-2"
>
{FILL_PRESETS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</div>
{/* Custom color picker (only when "color" selected) */}
{content === "color" && (
<div className="mb-3">
<span className="text-xs text-muted-foreground mb-1 block">Custom Color</span>
<input
type="color"
value={customColor}
onChange={(e) => setCustomColor(e.target.value)}
className="w-full h-8 border border-border rounded cursor-pointer"
/>
</div>
)}
{/* Opacity */}
<div className="mb-4">
<span className="text-xs text-muted-foreground mb-1 block">Opacity</span>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={100}
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="flex-1 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={100}
value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-14 h-7 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
{/* Color preview */}
<div className="mb-4 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Preview:</span>
<div
className="w-8 h-8 rounded border border-border"
style={{
backgroundColor: resolveColor(content, customColor, foregroundColor, backgroundColor),
opacity: opacity / 100,
}}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className={cn(
"px-3 py-1.5 text-xs rounded border border-border",
"hover:bg-muted transition-colors",
)}
>
Cancel
</button>
<button
type="button"
onClick={handleFill}
className={cn(
"px-3 py-1.5 text-xs rounded",
"bg-primary text-primary-foreground hover:bg-primary/90 transition-colors",
)}
>
OK
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,89 @@
// apps/web/src/components/editor/options/brush-options.tsx
import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor";
const BRUSH_OPTION_TOOLS = new Set<ToolType>(["brush", "eraser", "pencil"]);
export function BrushOptions() {
const activeTool = useEditorStore((s) => s.activeTool);
const brushSize = useEditorStore((s) => s.brushSize);
const brushOpacity = useEditorStore((s) => s.brushOpacity);
const brushHardness = useEditorStore((s) => s.brushHardness);
const setBrushSize = useEditorStore((s) => s.setBrushSize);
const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity);
const setBrushHardness = useEditorStore((s) => s.setBrushHardness);
if (!BRUSH_OPTION_TOOLS.has(activeTool)) return null;
return (
<div className="flex items-center gap-3">
{/* Size */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Size
<input
type="range"
min={1}
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={1}
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
{/* Opacity */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Opacity
<input
type="range"
min={0}
max={100}
value={Math.round(brushOpacity * 100)}
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={100}
value={Math.round(brushOpacity * 100)}
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-[10px]">%</span>
</label>
{/* Hardness (not for pencil -- pencil is always hard) */}
{activeTool !== "pencil" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Hardness
<input
type="range"
min={0}
max={100}
value={Math.round(brushHardness * 100)}
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={100}
value={Math.round(brushHardness * 100)}
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-[10px]">%</span>
</label>
)}
</div>
);
}
@@ -0,0 +1,105 @@
// apps/web/src/components/editor/options/clone-stamp-options.tsx
import { useCallback, useState } from "react";
import { useEditorStore } from "@/stores/editor-store";
import { getCloneAligned, setCloneAligned } from "../tools/clone-stamp-tool";
export function CloneStampOptions() {
const activeTool = useEditorStore((s) => s.activeTool);
const brushSize = useEditorStore((s) => s.brushSize);
const brushOpacity = useEditorStore((s) => s.brushOpacity);
const brushHardness = useEditorStore((s) => s.brushHardness);
const setBrushSize = useEditorStore((s) => s.setBrushSize);
const setBrushOpacity = useEditorStore((s) => s.setBrushOpacity);
const setBrushHardness = useEditorStore((s) => s.setBrushHardness);
const [aligned, setLocalAligned] = useState(getCloneAligned);
const handleAlignedChange = useCallback((value: boolean) => {
setCloneAligned(value);
setLocalAligned(value);
}, []);
if (activeTool !== "clone-stamp") return null;
return (
<div className="flex items-center gap-3">
{/* Size */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Size
<input
type="range"
min={1}
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={1}
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
{/* Opacity */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Opacity
<input
type="range"
min={0}
max={100}
value={Math.round(brushOpacity * 100)}
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={100}
value={Math.round(brushOpacity * 100)}
onChange={(e) => setBrushOpacity(Number(e.target.value) / 100)}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-[10px]">%</span>
</label>
{/* Hardness */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Hardness
<input
type="range"
min={0}
max={100}
value={Math.round(brushHardness * 100)}
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={100}
value={Math.round(brushHardness * 100)}
onChange={(e) => setBrushHardness(Number(e.target.value) / 100)}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-[10px]">%</span>
</label>
{/* Aligned */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={aligned}
onChange={(e) => handleAlignedChange(e.target.checked)}
className="accent-primary"
/>
Aligned
</label>
<span className="text-[10px] text-muted-foreground">Alt+Click to set source</span>
</div>
);
}
@@ -0,0 +1,163 @@
// apps/web/src/components/editor/options/dodge-burn-options.tsx
import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor";
const DODGE_BURN_TOOLS = new Set<ToolType>(["dodge", "burn", "sponge"]);
export function DodgeBurnOptions() {
const activeTool = useEditorStore((s) => s.activeTool);
const setTool = useEditorStore((s) => s.setTool);
const brushSize = useEditorStore((s) => s.brushSize);
const setBrushSize = useEditorStore((s) => s.setBrushSize);
const dodgeBurnRange = useEditorStore((s) => s.dodgeBurnRange);
const dodgeBurnExposure = useEditorStore((s) => s.dodgeBurnExposure);
const spongeMode = useEditorStore((s) => s.spongeMode);
const spongeFlow = useEditorStore((s) => s.spongeFlow);
if (!DODGE_BURN_TOOLS.has(activeTool)) return null;
const isDodgeBurn = activeTool === "dodge" || activeTool === "burn";
return (
<div className="flex items-center gap-3">
{/* Tool toggle */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Tool
<select
value={activeTool}
onChange={(e) => setTool(e.target.value as ToolType)}
className="h-6 text-xs bg-muted border border-border rounded px-1"
>
<option value="dodge">Dodge</option>
<option value="burn">Burn</option>
<option value="sponge">Sponge</option>
</select>
</label>
{/* Size */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Size
<input
type="range"
min={1}
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-16 h-1 accent-primary"
/>
<input
type="number"
min={1}
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
{/* Range (dodge/burn only) */}
{isDodgeBurn && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Range
<select
value={dodgeBurnRange}
onChange={(e) =>
useEditorStore.setState({
dodgeBurnRange: e.target.value as "shadows" | "midtones" | "highlights",
})
}
className="h-6 text-xs bg-muted border border-border rounded px-1"
>
<option value="shadows">Shadows</option>
<option value="midtones">Midtones</option>
<option value="highlights">Highlights</option>
</select>
</label>
)}
{/* Exposure (dodge/burn only) */}
{isDodgeBurn && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Exposure
<input
type="range"
min={1}
max={100}
value={dodgeBurnExposure}
onChange={(e) =>
useEditorStore.setState({
dodgeBurnExposure: Number(e.target.value),
})
}
className="w-16 h-1 accent-primary"
/>
<input
type="number"
min={1}
max={100}
value={dodgeBurnExposure}
onChange={(e) =>
useEditorStore.setState({
dodgeBurnExposure: Number(e.target.value),
})
}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-[10px]">%</span>
</label>
)}
{/* Sponge mode */}
{activeTool === "sponge" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Mode
<select
value={spongeMode}
onChange={(e) =>
useEditorStore.setState({
spongeMode: e.target.value as "saturate" | "desaturate",
})
}
className="h-6 text-xs bg-muted border border-border rounded px-1"
>
<option value="saturate">Saturate</option>
<option value="desaturate">Desaturate</option>
</select>
</label>
)}
{/* Flow (sponge only) */}
{activeTool === "sponge" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Flow
<input
type="range"
min={1}
max={100}
value={spongeFlow}
onChange={(e) =>
useEditorStore.setState({
spongeFlow: Number(e.target.value),
})
}
className="w-16 h-1 accent-primary"
/>
<input
type="number"
min={1}
max={100}
value={spongeFlow}
onChange={(e) =>
useEditorStore.setState({
spongeFlow: Number(e.target.value),
})
}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-[10px]">%</span>
</label>
)}
</div>
);
}
@@ -0,0 +1,64 @@
// apps/web/src/components/editor/options/fill-options.tsx
import { useCallback, useState } from "react";
import { useEditorStore } from "@/stores/editor-store";
import {
getFillContiguous,
getFillTolerance,
setFillContiguous,
setFillTolerance,
} from "../tools/fill-tool";
export function FillOptions() {
const activeTool = useEditorStore((s) => s.activeTool);
const [tolerance, setLocalTolerance] = useState(getFillTolerance);
const [contiguous, setLocalContiguous] = useState(getFillContiguous);
const handleToleranceChange = useCallback((value: number) => {
setFillTolerance(value);
setLocalTolerance(value);
}, []);
const handleContiguousChange = useCallback((value: boolean) => {
setFillContiguous(value);
setLocalContiguous(value);
}, []);
if (activeTool !== "fill") return null;
return (
<div className="flex items-center gap-3">
{/* Tolerance */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Tolerance
<input
type="range"
min={0}
max={255}
value={tolerance}
onChange={(e) => handleToleranceChange(Number(e.target.value))}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={255}
value={tolerance}
onChange={(e) => handleToleranceChange(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
{/* Contiguous */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={contiguous}
onChange={(e) => handleContiguousChange(e.target.checked)}
className="accent-primary"
/>
Contiguous
</label>
</div>
);
}
@@ -0,0 +1,87 @@
// apps/web/src/components/editor/options/gradient-options.tsx
import { useCallback, useState } from "react";
import { useEditorStore } from "@/stores/editor-store";
import {
type GradientType,
getGradientOpacity,
getGradientReverse,
getGradientType,
setGradientOpacity,
setGradientReverse,
setGradientType,
} from "../tools/gradient-tool";
export function GradientOptions() {
const activeTool = useEditorStore((s) => s.activeTool);
const [type, setLocalType] = useState<GradientType>(getGradientType);
const [opacity, setLocalOpacity] = useState(() => Math.round(getGradientOpacity() * 100));
const [reverse, setLocalReverse] = useState(getGradientReverse);
const handleTypeChange = useCallback((value: GradientType) => {
setGradientType(value);
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;
return (
<div className="flex items-center gap-3">
{/* Type toggle */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Type
<select
value={type}
onChange={(e) => handleTypeChange(e.target.value as GradientType)}
className="h-6 text-xs bg-muted border border-border rounded px-1"
>
<option value="linear">Linear</option>
<option value="radial">Radial</option>
</select>
</label>
{/* Opacity */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Opacity
<input
type="range"
min={0}
max={100}
value={opacity}
onChange={(e) => handleOpacityChange(Number(e.target.value))}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={100}
value={opacity}
onChange={(e) => handleOpacityChange(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-[10px]">%</span>
</label>
{/* Reverse */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={reverse}
onChange={(e) => handleReverseChange(e.target.checked)}
className="accent-primary"
/>
Reverse
</label>
</div>
);
}
@@ -0,0 +1,84 @@
// apps/web/src/components/editor/options/pixel-brush-options.tsx
import { useCallback, useState } from "react";
import { useEditorStore } from "@/stores/editor-store";
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"]);
export function PixelBrushOptions() {
const activeTool = useEditorStore((s) => s.activeTool);
const setTool = useEditorStore((s) => s.setTool);
const brushSize = useEditorStore((s) => s.brushSize);
const setBrushSize = useEditorStore((s) => s.setBrushSize);
const [strength, setLocalStrength] = useState(getPixelBrushStrength);
const handleStrengthChange = useCallback((value: number) => {
setPixelBrushStrength(value);
setLocalStrength(value);
}, []);
if (!PIXEL_BRUSH_TOOLS.has(activeTool)) return null;
return (
<div className="flex items-center gap-3">
{/* Tool toggle */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Tool
<select
value={activeTool}
onChange={(e) => setTool(e.target.value as ToolType)}
className="h-6 text-xs bg-muted border border-border rounded px-1"
>
<option value="blur-brush">Blur</option>
<option value="sharpen-brush">Sharpen</option>
<option value="smudge">Smudge</option>
</select>
</label>
{/* Size */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Size
<input
type="range"
min={1}
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={1}
max={500}
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
{/* Strength */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Strength
<input
type="range"
min={1}
max={100}
value={strength}
onChange={(e) => handleStrengthChange(Number(e.target.value))}
className="w-20 h-1 accent-primary"
/>
<input
type="number"
min={1}
max={100}
value={strength}
onChange={(e) => handleStrengthChange(Number(e.target.value))}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
<span className="text-[10px]">%</span>
</label>
</div>
);
}
@@ -0,0 +1,175 @@
// apps/web/src/components/editor/options/shape-options.tsx
import { useEditorStore } from "@/stores/editor-store";
import type { ToolType } from "@/types/editor";
const SHAPE_TOOLS = new Set<ToolType>([
"shape-rect",
"shape-ellipse",
"shape-line",
"shape-arrow",
"shape-polygon",
"shape-star",
]);
const SHAPE_TYPE_OPTIONS: { value: ToolType; label: string }[] = [
{ value: "shape-rect", label: "Rectangle" },
{ value: "shape-ellipse", label: "Ellipse" },
{ value: "shape-line", label: "Line" },
{ value: "shape-arrow", label: "Arrow" },
{ value: "shape-polygon", label: "Polygon" },
{ value: "shape-star", label: "Star" },
];
export function ShapeOptions() {
const activeTool = useEditorStore((s) => s.activeTool);
const setTool = useEditorStore((s) => s.setTool);
const shapeFill = useEditorStore((s) => s.shapeFill);
const shapeStroke = useEditorStore((s) => s.shapeStroke);
const shapeStrokeWidth = useEditorStore((s) => s.shapeStrokeWidth);
const shapeCornerRadius = useEditorStore((s) => s.shapeCornerRadius);
const shapePolygonSides = useEditorStore((s) => s.shapePolygonSides);
const shapeStarPoints = useEditorStore((s) => s.shapeStarPoints);
if (!SHAPE_TOOLS.has(activeTool)) return null;
return (
<div className="flex items-center gap-3">
{/* Shape type selector */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Shape
<select
value={activeTool}
onChange={(e) => setTool(e.target.value as ToolType)}
className="h-6 text-xs bg-muted border border-border rounded px-1"
>
{SHAPE_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
{/* Fill color */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Fill
<input
type="color"
value={shapeFill}
onChange={(e) => useEditorStore.setState({ shapeFill: e.target.value })}
className="w-6 h-6 border border-border rounded cursor-pointer"
/>
</label>
{/* Stroke color */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Stroke
<input
type="color"
value={shapeStroke}
onChange={(e) => useEditorStore.setState({ shapeStroke: e.target.value })}
className="w-6 h-6 border border-border rounded cursor-pointer"
/>
</label>
{/* Stroke width */}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Width
<input
type="range"
min={0}
max={50}
value={shapeStrokeWidth}
onChange={(e) =>
useEditorStore.setState({
shapeStrokeWidth: Number(e.target.value),
})
}
className="w-16 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={50}
value={shapeStrokeWidth}
onChange={(e) =>
useEditorStore.setState({
shapeStrokeWidth: Number(e.target.value),
})
}
className="w-10 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
{/* Corner radius (only for rect) */}
{activeTool === "shape-rect" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Radius
<input
type="range"
min={0}
max={100}
value={shapeCornerRadius}
onChange={(e) =>
useEditorStore.setState({
shapeCornerRadius: Number(e.target.value),
})
}
className="w-16 h-1 accent-primary"
/>
<input
type="number"
min={0}
max={100}
value={shapeCornerRadius}
onChange={(e) =>
useEditorStore.setState({
shapeCornerRadius: Number(e.target.value),
})
}
className="w-10 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
)}
{/* Polygon sides */}
{activeTool === "shape-polygon" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Sides
<input
type="number"
min={3}
max={20}
value={shapePolygonSides}
onChange={(e) =>
useEditorStore.setState({
shapePolygonSides: Number(e.target.value),
})
}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
)}
{/* Star points */}
{activeTool === "shape-star" && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
Points
<input
type="number"
min={3}
max={20}
value={shapeStarPoints}
onChange={(e) =>
useEditorStore.setState({
shapeStarPoints: Number(e.target.value),
})
}
className="w-12 h-6 text-xs text-center bg-muted border border-border rounded px-1"
/>
</label>
)}
</div>
);
}
@@ -0,0 +1,88 @@
// apps/web/src/components/editor/tools/brush-tool.tsx
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, LineAttrs } from "@/types/editor";
interface StrokeState {
points: number[];
objectId: string;
}
export function useBrushTool() {
const strokeRef = useRef<StrokeState | null>(null);
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (!stage) return;
const { activeTool, foregroundColor, brushSize, brushOpacity, brushHardness, zoom, panOffset } =
useEditorStore.getState();
if (activeTool !== "brush" && activeTool !== "pencil") return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = (pointer.x - panOffset.x) / zoom;
const y = (pointer.y - panOffset.y) / zoom;
const id = generateId();
const shadowBlurValue = activeTool === "pencil" ? 0 : brushSize * 0.4 * (1 - brushHardness);
const attrs: LineAttrs = {
points: [x, y],
stroke: foregroundColor,
strokeWidth: brushSize,
tension: activeTool === "pencil" ? 0 : 0.5,
lineCap: "round",
lineJoin: "round",
opacity: brushOpacity,
globalCompositeOperation: "source-over",
...(shadowBlurValue > 0 && {
shadowBlur: shadowBlurValue,
shadowColor: foregroundColor,
shadowOffsetX: 0,
shadowOffsetY: 0,
}),
};
const obj: CanvasObject = {
id,
type: "line",
layerId: useEditorStore.getState().activeLayerId,
attrs,
};
useEditorStore.getState().addObject(obj);
strokeRef.current = { points: [x, y], objectId: id };
}, []);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!strokeRef.current) return;
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;
strokeRef.current.points = [...strokeRef.current.points, x, y];
useEditorStore.getState().updateObject(strokeRef.current.objectId, {
points: [...strokeRef.current.points],
});
}, []);
const handleMouseUp = useCallback(() => {
strokeRef.current = null;
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
}
@@ -0,0 +1,213 @@
// apps/web/src/components/editor/tools/clone-stamp-tool.tsx
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
let cloneAligned = true;
export function setCloneAligned(value: boolean) {
cloneAligned = value;
}
export function getCloneAligned(): boolean {
return cloneAligned;
}
interface StampState {
objectId: string;
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
sourceSnapshot: ImageData;
startX: number;
startY: number;
offsetX: number;
offsetY: number;
}
export function useCloneStampTool(stageRef: React.RefObject<Konva.Stage | null>) {
const stampRef = useRef<StampState | null>(null);
const initialOffsetRef = useRef<{ x: number; y: number } | null>(null);
const handleMouseDown = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
const { activeTool, cloneSource, brushSize, brushOpacity, canvasSize, zoom, panOffset } =
useEditorStore.getState();
if (activeTool !== "clone-stamp") return;
const stage = stageRef.current;
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = Math.floor((pointer.x - panOffset.x) / zoom);
const y = Math.floor((pointer.y - panOffset.y) / zoom);
// Alt+click sets the clone source
if (e.evt.altKey) {
useEditorStore.setState({
cloneSource: { x, y, aligned: cloneAligned },
});
initialOffsetRef.current = null;
return;
}
if (!cloneSource) return;
// Capture a snapshot of the current stage pixels
const stageCanvas = stage.toCanvas({
pixelRatio: 1,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
const stageCtx = stageCanvas.getContext("2d");
if (!stageCtx) return;
const sourceSnapshot = stageCtx.getImageData(0, 0, canvasSize.width, canvasSize.height);
// Create an offscreen canvas for the clone output
const canvas = document.createElement("canvas");
canvas.width = canvasSize.width;
canvas.height = canvasSize.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// Compute offset from source to destination
let offsetX: number;
let offsetY: number;
if (cloneAligned && initialOffsetRef.current) {
offsetX = initialOffsetRef.current.x;
offsetY = initialOffsetRef.current.y;
} else {
offsetX = cloneSource.x - x;
offsetY = cloneSource.y - y;
if (cloneAligned) {
initialOffsetRef.current = { x: offsetX, y: offsetY };
}
}
// Paint the first dab
paintDab(ctx, sourceSnapshot, x, y, offsetX, offsetY, brushSize, brushOpacity, canvasSize);
const id = generateId();
const dataUrl = canvas.toDataURL();
const obj: CanvasObject = {
id,
type: "image",
layerId: useEditorStore.getState().activeLayerId,
attrs: {
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
rotation: 0,
opacity: 1,
src: dataUrl,
},
};
useEditorStore.getState().addObject(obj);
stampRef.current = {
objectId: id,
canvas,
ctx,
sourceSnapshot,
startX: x,
startY: y,
offsetX,
offsetY,
};
},
[stageRef],
);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!stampRef.current) return;
const { brushSize, brushOpacity, canvasSize, zoom, panOffset } = useEditorStore.getState();
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = Math.floor((pointer.x - panOffset.x) / zoom);
const y = Math.floor((pointer.y - panOffset.y) / zoom);
const { ctx, sourceSnapshot, offsetX, offsetY, canvas, objectId } = stampRef.current;
paintDab(ctx, sourceSnapshot, x, y, offsetX, offsetY, brushSize, brushOpacity, canvasSize);
const dataUrl = canvas.toDataURL();
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
}, []);
const handleMouseUp = useCallback(() => {
stampRef.current = null;
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
}
function paintDab(
ctx: CanvasRenderingContext2D,
source: ImageData,
destX: number,
destY: number,
offsetX: number,
offsetY: number,
brushSize: number,
opacity: number,
canvasSize: { width: number; height: number },
): void {
const halfSize = Math.floor(brushSize / 2);
const srcX = destX + offsetX;
const srcY = destY + offsetY;
for (let dy = -halfSize; dy <= halfSize; dy++) {
for (let dx = -halfSize; dx <= halfSize; dx++) {
// Circle mask
if (dx * dx + dy * dy > halfSize * halfSize) continue;
const px = destX + dx;
const py = destY + dy;
const sx = srcX + dx;
const sy = srcY + dy;
if (
px < 0 ||
px >= canvasSize.width ||
py < 0 ||
py >= canvasSize.height ||
sx < 0 ||
sx >= canvasSize.width ||
sy < 0 ||
sy >= canvasSize.height
) {
continue;
}
const si = (sy * canvasSize.width + sx) * 4;
const r = source.data[si];
const g = source.data[si + 1];
const b = source.data[si + 2];
const a = source.data[si + 3];
ctx.globalAlpha = (a / 255) * opacity;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(px, py, 1, 1);
}
}
ctx.globalAlpha = 1;
}
@@ -0,0 +1,262 @@
// apps/web/src/components/editor/tools/dodge-burn-tool.tsx
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, ToolType } from "@/types/editor";
const DODGE_BURN_TOOLS = new Set<ToolType>(["dodge", "burn", "sponge"]);
interface StrokeState {
objectId: string;
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
sourceSnapshot: ImageData;
}
function getRangeFactor(
r: number,
g: number,
b: number,
range: "shadows" | "midtones" | "highlights",
): number {
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
switch (range) {
case "shadows":
return luminance < 85 ? 1 : luminance < 128 ? (128 - luminance) / 43 : 0;
case "highlights":
return luminance > 170 ? 1 : luminance > 128 ? (luminance - 128) / 42 : 0;
default:
if (luminance < 64) return luminance / 64;
if (luminance > 192) return (255 - luminance) / 63;
return 1;
}
}
function rgbToHsl(rIn: number, gIn: number, bIn: number): [number, number, number] {
const r = rIn / 255;
const g = gIn / 255;
const b = bIn / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return [h, s, l];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
if (s === 0) {
const v = Math.round(l * 255);
return [v, v, v];
}
const hue2rgb = (p: number, q: number, t: number) => {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return [
Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
Math.round(hue2rgb(p, q, h) * 255),
Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
];
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function useDodgeBurnTool(stageRef: React.RefObject<Konva.Stage | null>) {
const strokeRef = useRef<StrokeState | null>(null);
const handleMouseDown = useCallback(
(_e: Konva.KonvaEventObject<MouseEvent>) => {
const { activeTool, canvasSize, zoom, panOffset } = useEditorStore.getState();
if (!DODGE_BURN_TOOLS.has(activeTool)) return;
const stage = stageRef.current;
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = Math.floor((pointer.x - panOffset.x) / zoom);
const y = Math.floor((pointer.y - panOffset.y) / zoom);
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
// Snapshot current stage pixels
const stageCanvas = stage.toCanvas({
pixelRatio: 1,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
const stageCtx = stageCanvas.getContext("2d");
if (!stageCtx) return;
const sourceSnapshot = stageCtx.getImageData(0, 0, canvasSize.width, canvasSize.height);
// Create output canvas
const canvas = document.createElement("canvas");
canvas.width = canvasSize.width;
canvas.height = canvasSize.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.putImageData(sourceSnapshot, 0, 0);
applyBrushDab(ctx, sourceSnapshot, x, y, canvasSize);
const id = generateId();
const dataUrl = canvas.toDataURL();
const obj: CanvasObject = {
id,
type: "image",
layerId: useEditorStore.getState().activeLayerId,
attrs: {
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
rotation: 0,
opacity: 1,
src: dataUrl,
},
};
useEditorStore.getState().addObject(obj);
strokeRef.current = { objectId: id, canvas, ctx, sourceSnapshot };
},
[stageRef],
);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!strokeRef.current) return;
const { canvasSize, zoom, panOffset } = useEditorStore.getState();
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = Math.floor((pointer.x - panOffset.x) / zoom);
const y = Math.floor((pointer.y - panOffset.y) / zoom);
const { ctx, sourceSnapshot, canvas, objectId } = strokeRef.current;
applyBrushDab(ctx, sourceSnapshot, x, y, canvasSize);
const dataUrl = canvas.toDataURL();
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
}, []);
const handleMouseUp = useCallback(() => {
strokeRef.current = null;
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
}
function applyBrushDab(
ctx: CanvasRenderingContext2D,
_source: ImageData,
centerX: number,
centerY: number,
canvasSize: { width: number; height: number },
): void {
const { activeTool, brushSize, dodgeBurnExposure, dodgeBurnRange, spongeMode, spongeFlow } =
useEditorStore.getState();
const halfSize = Math.floor(brushSize / 2);
const exposure = dodgeBurnExposure / 100;
const flow = spongeFlow / 100;
const imageData = ctx.getImageData(
Math.max(0, centerX - halfSize),
Math.max(0, centerY - halfSize),
Math.min(canvasSize.width, centerX + halfSize + 1) - Math.max(0, centerX - halfSize),
Math.min(canvasSize.height, centerY + halfSize + 1) - Math.max(0, centerY - halfSize),
);
const startPx = Math.max(0, centerX - halfSize);
const startPy = Math.max(0, centerY - halfSize);
for (let py = 0; py < imageData.height; py++) {
for (let px = 0; px < imageData.width; px++) {
const dx = startPx + px - centerX;
const dy = startPy + py - centerY;
if (dx * dx + dy * dy > halfSize * halfSize) continue;
const idx = (py * imageData.width + px) * 4;
let r = imageData.data[idx];
let g = imageData.data[idx + 1];
let b = imageData.data[idx + 2];
if (activeTool === "dodge") {
const factor = getRangeFactor(r, g, b, dodgeBurnRange);
const multiplier = 1 + exposure * factor;
r = clamp(Math.round(r * multiplier), 0, 255);
g = clamp(Math.round(g * multiplier), 0, 255);
b = clamp(Math.round(b * multiplier), 0, 255);
} else if (activeTool === "burn") {
const factor = getRangeFactor(r, g, b, dodgeBurnRange);
const multiplier = 1 - exposure * factor;
r = clamp(Math.round(r * multiplier), 0, 255);
g = clamp(Math.round(g * multiplier), 0, 255);
b = clamp(Math.round(b * multiplier), 0, 255);
} else if (activeTool === "sponge") {
const [h, s, l] = rgbToHsl(r, g, b);
let newS: number;
if (spongeMode === "saturate") {
newS = Math.min(1, s + flow * 0.1);
} else {
newS = Math.max(0, s - flow * 0.1);
}
const [nr, ng, nb] = hslToRgb(h, newS, l);
r = nr;
g = ng;
b = nb;
}
imageData.data[idx] = r;
imageData.data[idx + 1] = g;
imageData.data[idx + 2] = b;
}
}
ctx.putImageData(imageData, startPx, startPy);
}
@@ -0,0 +1,88 @@
// apps/web/src/components/editor/tools/eraser-tool.tsx
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, LineAttrs } from "@/types/editor";
interface StrokeState {
points: number[];
objectId: string;
}
export function useEraserTool() {
const strokeRef = useRef<StrokeState | null>(null);
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (!stage) return;
const { activeTool, brushSize, brushOpacity, brushHardness, zoom, panOffset } =
useEditorStore.getState();
if (activeTool !== "eraser") return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = (pointer.x - panOffset.x) / zoom;
const y = (pointer.y - panOffset.y) / zoom;
const id = generateId();
const shadowBlurValue = brushSize * 0.4 * (1 - brushHardness);
const attrs: LineAttrs = {
points: [x, y],
stroke: "#000000",
strokeWidth: brushSize,
tension: 0.5,
lineCap: "round",
lineJoin: "round",
opacity: brushOpacity,
globalCompositeOperation: "destination-out",
...(shadowBlurValue > 0 && {
shadowBlur: shadowBlurValue,
shadowColor: "#000000",
shadowOffsetX: 0,
shadowOffsetY: 0,
}),
};
const obj: CanvasObject = {
id,
type: "line",
layerId: useEditorStore.getState().activeLayerId,
attrs,
};
useEditorStore.getState().addObject(obj);
strokeRef.current = { points: [x, y], objectId: id };
}, []);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!strokeRef.current) return;
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;
strokeRef.current.points = [...strokeRef.current.points, x, y];
useEditorStore.getState().updateObject(strokeRef.current.objectId, {
points: [...strokeRef.current.points],
});
}, []);
const handleMouseUp = useCallback(() => {
strokeRef.current = null;
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
}
@@ -0,0 +1,226 @@
// apps/web/src/components/editor/tools/fill-tool.tsx
import type Konva from "konva";
import { useCallback } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
/** Tolerance for flood fill (0-255). Stored outside store for simplicity. */
let fillTolerance = 32;
let fillContiguous = true;
export function setFillTolerance(value: number) {
fillTolerance = Math.max(0, Math.min(255, value));
}
export function getFillTolerance(): number {
return fillTolerance;
}
export function setFillContiguous(value: boolean) {
fillContiguous = value;
}
export function getFillContiguous(): boolean {
return fillContiguous;
}
function colorDistance(
r1: number,
g1: number,
b1: number,
r2: number,
g2: number,
b2: number,
): number {
const dr = r1 - r2;
const dg = g1 - g2;
const db = b1 - b2;
return Math.sqrt(dr * dr + dg * dg + db * db);
}
function hexToRgb(hex: string): [number, number, number] {
const num = Number.parseInt(hex.replace("#", ""), 16);
return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff];
}
/**
* Scanline flood fill algorithm.
* Fills pixels within tolerance of the target color with the replacement color.
*/
function floodFill(
imageData: ImageData,
startX: number,
startY: number,
fillR: number,
fillG: number,
fillB: number,
tolerance: number,
contiguous: boolean,
): void {
const { width, height, data } = imageData;
const startIdx = (startY * width + startX) * 4;
const targetR = data[startIdx];
const targetG = data[startIdx + 1];
const targetB = data[startIdx + 2];
// Already same color, skip
if (fillR === targetR && fillG === targetG && fillB === targetB) return;
const maxDist = tolerance * Math.sqrt(3);
if (!contiguous) {
// Non-contiguous: fill all pixels matching target color
for (let i = 0; i < data.length; i += 4) {
const dist = colorDistance(data[i], data[i + 1], data[i + 2], targetR, targetG, targetB);
if (dist <= maxDist) {
data[i] = fillR;
data[i + 1] = fillG;
data[i + 2] = fillB;
data[i + 3] = 255;
}
}
return;
}
// Contiguous scanline flood fill
const visited = new Uint8Array(width * height);
function matches(idx: number): boolean {
const pi = idx * 4;
if (visited[idx]) return false;
return (
colorDistance(data[pi], data[pi + 1], data[pi + 2], targetR, targetG, targetB) <= maxDist
);
}
function fillPixel(idx: number): void {
const pi = idx * 4;
data[pi] = fillR;
data[pi + 1] = fillG;
data[pi + 2] = fillB;
data[pi + 3] = 255;
visited[idx] = 1;
}
const stack: [number, number][] = [[startX, startY]];
while (stack.length > 0) {
const entry = stack.pop();
if (!entry) break;
const [sx, sy] = entry;
let x = sx;
// Move left to find the start of the line
while (x > 0 && matches(sy * width + (x - 1))) {
x--;
}
let spanAbove = false;
let spanBelow = false;
while (x < width && matches(sy * width + x)) {
fillPixel(sy * width + x);
// Check above
if (sy > 0) {
const aboveIdx = (sy - 1) * width + x;
if (matches(aboveIdx)) {
if (!spanAbove) {
stack.push([x, sy - 1]);
spanAbove = true;
}
} else {
spanAbove = false;
}
}
// Check below
if (sy < height - 1) {
const belowIdx = (sy + 1) * width + x;
if (matches(belowIdx)) {
if (!spanBelow) {
stack.push([x, sy + 1]);
spanBelow = true;
}
} else {
spanBelow = false;
}
}
x++;
}
}
}
export function useFillTool(stageRef: React.RefObject<Konva.Stage | null>) {
const handleMouseDown = useCallback(
(_e: Konva.KonvaEventObject<MouseEvent>) => {
const { activeTool, foregroundColor, canvasSize, zoom, panOffset } =
useEditorStore.getState();
if (activeTool !== "fill") return;
const stage = stageRef.current;
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = Math.floor((pointer.x - panOffset.x) / zoom);
const y = Math.floor((pointer.y - panOffset.y) / zoom);
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
// Export the current stage to a canvas for pixel access
const stageCanvas = stage.toCanvas({
pixelRatio: 1,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
const ctx = stageCanvas.getContext("2d");
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, canvasSize.width, canvasSize.height);
const [fillR, fillG, fillB] = hexToRgb(foregroundColor);
floodFill(imageData, x, y, fillR, fillG, fillB, fillTolerance, fillContiguous);
// Put the modified data back and create an image object
ctx.putImageData(imageData, 0, 0);
const dataUrl = stageCanvas.toDataURL();
const obj: CanvasObject = {
id: generateId(),
type: "image",
layerId: useEditorStore.getState().activeLayerId,
attrs: {
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
rotation: 0,
opacity: 1,
src: dataUrl,
},
};
useEditorStore.getState().addObject(obj);
},
[stageRef],
);
const handleMouseMove = useCallback((_e: Konva.KonvaEventObject<MouseEvent>) => {
// No-op: fill is a single-click operation
}, []);
const handleMouseUp = useCallback(() => {
// No-op
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
}
@@ -0,0 +1,141 @@
// apps/web/src/components/editor/tools/gradient-tool.tsx
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject } from "@/types/editor";
export type GradientType = "linear" | "radial";
let gradientType: GradientType = "linear";
let gradientOpacity = 1;
let gradientReverse = false;
export function setGradientType(type: GradientType) {
gradientType = type;
}
export function getGradientType(): GradientType {
return gradientType;
}
export function setGradientOpacity(value: number) {
gradientOpacity = Math.max(0, Math.min(1, value));
}
export function getGradientOpacity(): number {
return gradientOpacity;
}
export function setGradientReverse(value: boolean) {
gradientReverse = value;
}
export function getGradientReverse(): boolean {
return gradientReverse;
}
interface DragState {
startX: number;
startY: number;
}
export function useGradientTool() {
const dragRef = useRef<DragState | null>(null);
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
const { activeTool, zoom, panOffset } = useEditorStore.getState();
if (activeTool !== "gradient") return;
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = (pointer.x - panOffset.x) / zoom;
const y = (pointer.y - panOffset.y) / zoom;
dragRef.current = { startX: x, startY: y };
}, []);
const handleMouseMove = useCallback((_e: Konva.KonvaEventObject<MouseEvent>) => {
// Could show a preview line/circle here; keeping simple for now
}, []);
const handleMouseUp = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!dragRef.current) return;
const { foregroundColor, backgroundColor, canvasSize, zoom, panOffset } =
useEditorStore.getState();
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const endX = (pointer.x - panOffset.x) / zoom;
const endY = (pointer.y - panOffset.y) / zoom;
const { startX, startY } = dragRef.current;
const dx = endX - startX;
const dy = endY - startY;
if (Math.sqrt(dx * dx + dy * dy) < 2) {
dragRef.current = null;
return;
}
// Build gradient on an offscreen canvas
const canvas = document.createElement("canvas");
canvas.width = canvasSize.width;
canvas.height = canvasSize.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
dragRef.current = null;
return;
}
const color1 = gradientReverse ? backgroundColor : foregroundColor;
const color2 = gradientReverse ? foregroundColor : backgroundColor;
let gradient: CanvasGradient;
if (gradientType === "radial") {
const radius = Math.sqrt(dx * dx + dy * dy);
gradient = ctx.createRadialGradient(startX, startY, 0, startX, startY, radius);
} else {
gradient = ctx.createLinearGradient(startX, startY, endX, endY);
}
gradient.addColorStop(0, color1);
gradient.addColorStop(1, color2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvasSize.width, canvasSize.height);
const dataUrl = canvas.toDataURL();
const obj: CanvasObject = {
id: generateId(),
type: "image",
layerId: useEditorStore.getState().activeLayerId,
attrs: {
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
rotation: 0,
opacity: gradientOpacity,
src: dataUrl,
},
};
useEditorStore.getState().addObject(obj);
dragRef.current = null;
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
}
@@ -0,0 +1,356 @@
// apps/web/src/components/editor/tools/pixel-brush-tool.tsx
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, ToolType } from "@/types/editor";
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 {
objectId: string;
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
sourceSnapshot: ImageData;
lastX: number;
lastY: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function usePixelBrushTool(stageRef: React.RefObject<Konva.Stage | null>) {
const strokeRef = useRef<StrokeState | null>(null);
const handleMouseDown = useCallback(
(_e: Konva.KonvaEventObject<MouseEvent>) => {
const { activeTool, canvasSize, zoom, panOffset } = useEditorStore.getState();
if (!PIXEL_BRUSH_TOOLS.has(activeTool)) return;
const stage = stageRef.current;
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = Math.floor((pointer.x - panOffset.x) / zoom);
const y = Math.floor((pointer.y - panOffset.y) / zoom);
if (x < 0 || x >= canvasSize.width || y < 0 || y >= canvasSize.height) return;
// Snapshot stage
const stageCanvas = stage.toCanvas({
pixelRatio: 1,
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
});
const stageCtx = stageCanvas.getContext("2d");
if (!stageCtx) return;
const sourceSnapshot = stageCtx.getImageData(0, 0, canvasSize.width, canvasSize.height);
// Create output canvas
const canvas = document.createElement("canvas");
canvas.width = canvasSize.width;
canvas.height = canvasSize.height;
const ctx = canvas.getContext("2d");
if (!ctx) return;
ctx.putImageData(sourceSnapshot, 0, 0);
applyPixelBrush(ctx, sourceSnapshot, x, y, canvasSize);
const id = generateId();
const dataUrl = canvas.toDataURL();
const obj: CanvasObject = {
id,
type: "image",
layerId: useEditorStore.getState().activeLayerId,
attrs: {
x: 0,
y: 0,
width: canvasSize.width,
height: canvasSize.height,
rotation: 0,
opacity: 1,
src: dataUrl,
},
};
useEditorStore.getState().addObject(obj);
strokeRef.current = { objectId: id, canvas, ctx, sourceSnapshot, lastX: x, lastY: y };
},
[stageRef],
);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!strokeRef.current) return;
const { canvasSize, zoom, panOffset } = useEditorStore.getState();
const stage = e.target.getStage();
if (!stage) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = Math.floor((pointer.x - panOffset.x) / zoom);
const y = Math.floor((pointer.y - panOffset.y) / zoom);
const { ctx, sourceSnapshot, canvas, objectId } = strokeRef.current;
applyPixelBrush(ctx, sourceSnapshot, x, y, canvasSize);
// Update source snapshot for smudge continuity
const updatedData = ctx.getImageData(0, 0, canvasSize.width, canvasSize.height);
strokeRef.current.sourceSnapshot = updatedData;
strokeRef.current.lastX = x;
strokeRef.current.lastY = y;
const dataUrl = canvas.toDataURL();
useEditorStore.getState().updateObject(objectId, { src: dataUrl });
}, []);
const handleMouseUp = useCallback(() => {
strokeRef.current = null;
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
}
function applyPixelBrush(
ctx: CanvasRenderingContext2D,
source: ImageData,
centerX: number,
centerY: number,
canvasSize: { width: number; height: number },
): void {
const { activeTool, brushSize } = useEditorStore.getState();
const strength = pixelBrushStrength / 100;
const halfSize = Math.floor(brushSize / 2);
const left = Math.max(0, centerX - halfSize);
const top = Math.max(0, centerY - halfSize);
const right = Math.min(canvasSize.width, centerX + halfSize + 1);
const bottom = Math.min(canvasSize.height, centerY + halfSize + 1);
const w = right - left;
const h = bottom - top;
if (w <= 0 || h <= 0) return;
const imageData = ctx.getImageData(left, top, w, h);
if (activeTool === "blur-brush") {
applyBoxBlur(imageData, source, left, top, canvasSize.width, halfSize, strength);
} else if (activeTool === "sharpen-brush") {
applySharpen(
imageData,
source,
left,
top,
centerX,
centerY,
canvasSize.width,
halfSize,
strength,
);
} else if (activeTool === "smudge") {
applySmudge(
imageData,
source,
left,
top,
centerX,
centerY,
canvasSize.width,
halfSize,
strength,
);
}
ctx.putImageData(imageData, left, top);
}
function applyBoxBlur(
imageData: ImageData,
source: ImageData,
startX: number,
startY: number,
sourceWidth: number,
halfSize: number,
strength: number,
): void {
const kernelSize = Math.max(1, Math.round(strength * 3));
for (let py = 0; py < imageData.height; py++) {
for (let px = 0; px < imageData.width; px++) {
const dx = startX + px - (startX + imageData.width / 2);
const dy = startY + py - (startY + imageData.height / 2);
if (dx * dx + dy * dy > halfSize * halfSize) continue;
let sumR = 0;
let sumG = 0;
let sumB = 0;
let count = 0;
for (let ky = -kernelSize; ky <= kernelSize; ky++) {
for (let kx = -kernelSize; kx <= kernelSize; kx++) {
const sx = startX + px + kx;
const sy = startY + py + ky;
if (
sx < 0 ||
sx >= sourceWidth ||
sy < 0 ||
sy >= source.height / (source.width / sourceWidth)
) {
continue;
}
const si = (sy * sourceWidth + sx) * 4;
sumR += source.data[si];
sumG += source.data[si + 1];
sumB += source.data[si + 2];
count++;
}
}
if (count > 0) {
const idx = (py * imageData.width + px) * 4;
imageData.data[idx] = Math.round(sumR / count);
imageData.data[idx + 1] = Math.round(sumG / count);
imageData.data[idx + 2] = Math.round(sumB / count);
}
}
}
}
function applySharpen(
imageData: ImageData,
source: ImageData,
startX: number,
startY: number,
centerX: number,
centerY: number,
sourceWidth: number,
halfSize: number,
strength: number,
): void {
const factor = 1 + strength * 4;
for (let py = 0; py < imageData.height; py++) {
for (let px = 0; px < imageData.width; px++) {
const dxC = startX + px - centerX;
const dyC = startY + py - centerY;
if (dxC * dxC + dyC * dyC > halfSize * halfSize) continue;
const sx = startX + px;
const sy = startY + py;
const ci = (sy * sourceWidth + sx) * 4;
// Simple unsharp: pixel + factor * (pixel - average of neighbors)
let avgR = 0;
let avgG = 0;
let avgB = 0;
let count = 0;
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
if (kx === 0 && ky === 0) continue;
const nx = sx + kx;
const ny = sy + ky;
if (nx >= 0 && nx < sourceWidth && ny >= 0) {
const ni = (ny * sourceWidth + nx) * 4;
if (ni >= 0 && ni < source.data.length) {
avgR += source.data[ni];
avgG += source.data[ni + 1];
avgB += source.data[ni + 2];
count++;
}
}
}
}
if (count > 0) {
avgR /= count;
avgG /= count;
avgB /= count;
}
const idx = (py * imageData.width + px) * 4;
imageData.data[idx] = clamp(
Math.round(source.data[ci] + factor * (source.data[ci] - avgR)),
0,
255,
);
imageData.data[idx + 1] = clamp(
Math.round(source.data[ci + 1] + factor * (source.data[ci + 1] - avgG)),
0,
255,
);
imageData.data[idx + 2] = clamp(
Math.round(source.data[ci + 2] + factor * (source.data[ci + 2] - avgB)),
0,
255,
);
}
}
}
function applySmudge(
imageData: ImageData,
source: ImageData,
startX: number,
startY: number,
centerX: number,
centerY: number,
sourceWidth: number,
halfSize: number,
strength: number,
): void {
// Smudge: blend current pixel with its neighbor in the direction of movement
for (let py = 0; py < imageData.height; py++) {
for (let px = 0; px < imageData.width; px++) {
const dxC = startX + px - centerX;
const dyC = startY + py - centerY;
if (dxC * dxC + dyC * dyC > halfSize * halfSize) continue;
const sx = startX + px;
const sy = startY + py;
const ci = (sy * sourceWidth + sx) * 4;
// Blend with the pixel at previous position
const prevX = sx - Math.sign(dxC || 1);
const prevY = sy - Math.sign(dyC || 1);
if (prevX >= 0 && prevX < sourceWidth && prevY >= 0) {
const pi = (prevY * sourceWidth + prevX) * 4;
if (pi >= 0 && pi + 2 < source.data.length) {
const idx = (py * imageData.width + px) * 4;
imageData.data[idx] = Math.round(
source.data[ci] * (1 - strength) + source.data[pi] * strength,
);
imageData.data[idx + 1] = Math.round(
source.data[ci + 1] * (1 - strength) + source.data[pi + 1] * strength,
);
imageData.data[idx + 2] = Math.round(
source.data[ci + 2] * (1 - strength) + source.data[pi + 2] * strength,
);
}
}
}
}
}
@@ -0,0 +1,313 @@
// apps/web/src/components/editor/tools/shape-tool.tsx
import type Konva from "konva";
import { useCallback, useRef } from "react";
import { generateId } from "@/lib/utils";
import { useEditorStore } from "@/stores/editor-store";
import type { CanvasObject, ToolType } from "@/types/editor";
interface DragState {
startX: number;
startY: number;
objectId: string;
toolType: ToolType;
}
const SHAPE_TOOLS = new Set<ToolType>([
"shape-rect",
"shape-ellipse",
"shape-line",
"shape-arrow",
"shape-polygon",
"shape-star",
]);
function constrainToDimension(
startX: number,
startY: number,
currentX: number,
currentY: number,
shiftHeld: boolean,
): { w: number; h: number } {
let w = currentX - startX;
let h = currentY - startY;
if (shiftHeld) {
const size = Math.max(Math.abs(w), Math.abs(h));
w = size * Math.sign(w || 1);
h = size * Math.sign(h || 1);
}
return { w, h };
}
export function useShapeTool() {
const dragRef = useRef<DragState | null>(null);
const handleMouseDown = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
const stage = e.target.getStage();
if (!stage) return;
const {
activeTool,
shapeFill,
shapeStroke,
shapeStrokeWidth,
shapeCornerRadius,
shapePolygonSides,
shapeStarPoints,
activeLayerId,
zoom,
panOffset,
} = useEditorStore.getState();
if (!SHAPE_TOOLS.has(activeTool)) return;
const pointer = stage.getPointerPosition();
if (!pointer) return;
const x = (pointer.x - panOffset.x) / zoom;
const y = (pointer.y - panOffset.y) / zoom;
const id = generateId();
let obj: CanvasObject;
switch (activeTool) {
case "shape-rect":
obj = {
id,
type: "rect",
layerId: activeLayerId,
attrs: {
x,
y,
width: 0,
height: 0,
fill: shapeFill,
stroke: shapeStroke,
strokeWidth: shapeStrokeWidth,
cornerRadius: shapeCornerRadius,
rotation: 0,
opacity: 1,
},
};
break;
case "shape-ellipse":
obj = {
id,
type: "ellipse",
layerId: activeLayerId,
attrs: {
x,
y,
radiusX: 0,
radiusY: 0,
fill: shapeFill,
stroke: shapeStroke,
strokeWidth: shapeStrokeWidth,
rotation: 0,
opacity: 1,
},
};
break;
case "shape-line":
obj = {
id,
type: "line",
layerId: activeLayerId,
attrs: {
points: [x, y, x, y],
stroke: shapeStroke,
strokeWidth: shapeStrokeWidth,
tension: 0,
lineCap: "round",
lineJoin: "round",
opacity: 1,
globalCompositeOperation: "source-over",
},
};
break;
case "shape-arrow":
obj = {
id,
type: "arrow",
layerId: activeLayerId,
attrs: {
points: [x, y, x, y],
fill: shapeFill,
stroke: shapeStroke,
strokeWidth: shapeStrokeWidth,
pointerLength: 15,
pointerWidth: 12,
rotation: 0,
opacity: 1,
},
};
break;
case "shape-polygon":
obj = {
id,
type: "polygon",
layerId: activeLayerId,
attrs: {
x,
y,
sides: shapePolygonSides,
radius: 0,
fill: shapeFill,
stroke: shapeStroke,
strokeWidth: shapeStrokeWidth,
rotation: 0,
opacity: 1,
},
};
break;
case "shape-star":
obj = {
id,
type: "star",
layerId: activeLayerId,
attrs: {
x,
y,
numPoints: shapeStarPoints,
innerRadius: 0,
outerRadius: 0,
fill: shapeFill,
stroke: shapeStroke,
strokeWidth: shapeStrokeWidth,
rotation: 0,
opacity: 1,
},
};
break;
default:
return;
}
useEditorStore.getState().addObject(obj);
dragRef.current = { startX: x, startY: y, objectId: id, toolType: activeTool };
}, []);
const handleMouseMove = useCallback((e: Konva.KonvaEventObject<MouseEvent>) => {
if (!dragRef.current) return;
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 { startX, startY, objectId, toolType } = dragRef.current;
const shiftHeld = e.evt.shiftKey;
switch (toolType) {
case "shape-rect": {
const { w, h } = constrainToDimension(startX, startY, x, y, shiftHeld);
const rx = w < 0 ? startX + w : startX;
const ry = h < 0 ? startY + h : startY;
useEditorStore.getState().updateObject(objectId, {
x: rx,
y: ry,
width: Math.abs(w),
height: Math.abs(h),
});
break;
}
case "shape-ellipse": {
const { w, h } = constrainToDimension(startX, startY, x, y, shiftHeld);
useEditorStore.getState().updateObject(objectId, {
x: startX + w / 2,
y: startY + h / 2,
radiusX: Math.abs(w) / 2,
radiusY: Math.abs(h) / 2,
});
break;
}
case "shape-line": {
let endX = x;
let endY = y;
if (shiftHeld) {
const dx = x - startX;
const dy = y - startY;
const angle = Math.atan2(dy, dx);
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
const dist = Math.sqrt(dx * dx + dy * dy);
endX = startX + Math.cos(snapped) * dist;
endY = startY + Math.sin(snapped) * dist;
}
useEditorStore.getState().updateObject(objectId, {
points: [startX, startY, endX, endY],
});
break;
}
case "shape-arrow": {
let endX = x;
let endY = y;
if (shiftHeld) {
const dx = x - startX;
const dy = y - startY;
const angle = Math.atan2(dy, dx);
const snapped = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4);
const dist = Math.sqrt(dx * dx + dy * dy);
endX = startX + Math.cos(snapped) * dist;
endY = startY + Math.sin(snapped) * dist;
}
useEditorStore.getState().updateObject(objectId, {
points: [startX, startY, endX, endY],
});
break;
}
case "shape-polygon": {
const dx = x - startX;
const dy = y - startY;
const radius = Math.sqrt(dx * dx + dy * dy);
useEditorStore.getState().updateObject(objectId, { radius });
break;
}
case "shape-star": {
const dx = x - startX;
const dy = y - startY;
const outerRadius = Math.sqrt(dx * dx + dy * dy);
useEditorStore.getState().updateObject(objectId, {
outerRadius,
innerRadius: outerRadius * 0.4,
});
break;
}
}
}, []);
const handleMouseUp = useCallback(() => {
if (!dragRef.current) return;
const { objectId } = dragRef.current;
const objects = useEditorStore.getState().objects;
const obj = objects.find((o) => o.id === objectId);
if (obj) {
// Remove zero-size shapes
const attrs = obj.attrs;
let isDegenerate = false;
if ("width" in attrs && "height" in attrs) {
isDegenerate =
(attrs as { width: number }).width < 1 && (attrs as { height: number }).height < 1;
} else if ("radius" in attrs) {
isDegenerate = (attrs as { radius: number }).radius < 1;
} else if ("outerRadius" in attrs) {
isDegenerate = (attrs as { outerRadius: number }).outerRadius < 1;
}
if (isDegenerate) {
useEditorStore.getState().removeObjects([objectId]);
} else {
useEditorStore.getState().setSelectedObjects([objectId]);
}
}
dragRef.current = null;
}, []);
return { handleMouseDown, handleMouseMove, handleMouseUp };
}