Files
SnapOtter/apps/web/src/components/editor/tools/gradient-tool.tsx
T
SnapOtter 87bbf2f889 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
2026-05-06 23:24:34 +08:00

142 lines
3.7 KiB
TypeScript

// 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 };
}