mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-agent-ac1b61d3' into feat/image-editor
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