feat: add drawing and shape tools for image editor

Implement 8 tool hooks, 7 options bar components, and 1 fill dialog:

Tools:
- Brush: Konva.Line with smooth strokes, hardness via shadowBlur
- Eraser: brush with globalCompositeOperation destination-out
- Shape: Rectangle, Ellipse, Line, Arrow, Polygon, Star with Shift constraint
- Fill (Paint Bucket): scanline flood fill with tolerance and contiguous mode
- Gradient: linear/radial via drag gesture on offscreen canvas
- Clone Stamp: Alt+click source, paint cloned pixels with aligned mode
- Dodge/Burn/Sponge: per-pixel brightness/saturation manipulation by range
- Pixel Brush: blur (box blur), sharpen (unsharp), smudge (directional blend)

Options bars:
- Brush: size/opacity/hardness sliders
- Shape: type selector, fill/stroke color, stroke width, corner radius, sides
- Fill: tolerance slider, contiguous checkbox
- Gradient: linear/radial toggle, opacity, reverse
- Clone Stamp: size/opacity/hardness, aligned toggle
- Dodge/Burn: tool toggle, range dropdown, exposure/flow sliders
- Pixel Brush: tool toggle, size, strength

Common:
- Fill Dialog: Shift+Backspace modal with foreground/background/color/white/black/50% gray
This commit is contained in:
SnapOtter
2026-05-06 23:24:34 +08:00
parent 9eae8369bb
commit 87bbf2f889
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>
);
}