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