From 8f595ac6c4a011b6954a45dcebbe6792fc59d04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 21 Nov 2025 19:48:00 +0100 Subject: [PATCH] feat(phase-13): implement gradient tool with linear, radial, and angular modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive gradient tool with three gradient types and full UI integration. Features: - Gradient tool with drag-to-create interaction - Three gradient types: Linear, Radial, and Angular (conic) - Live preview during drag with 70% opacity overlay - Primary and secondary color selection - Gradient type selector in tool options - Undo/redo support through command system - Fallback to radial gradient for browsers without conic gradient support Changes: - Created tools/gradient-tool.ts with GradientTool class - Added 'gradient' to ToolType in types/tool.ts - Extended ToolSettings with secondaryColor and gradientType - Updated store/tool-store.ts with setSecondaryColor and setGradientType methods - Added gradient tool loading in lib/tool-loader.ts - Added gradient button to tool palette with 'G' shortcut - Added gradient tool options UI in components/editor/tool-options.tsx 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/editor/tool-options.tsx | 80 ++++++++++++++++- components/tools/tool-palette.tsx | 2 + lib/tool-loader.ts | 5 ++ store/tool-store.ts | 19 ++++ tools/gradient-tool.ts | 138 +++++++++++++++++++++++++++++ tools/index.ts | 1 + types/tool.ts | 5 ++ 7 files changed, 248 insertions(+), 2 deletions(-) create mode 100644 tools/gradient-tool.ts diff --git a/components/editor/tool-options.tsx b/components/editor/tool-options.tsx index c244b0b..feb4ef2 100644 --- a/components/editor/tool-options.tsx +++ b/components/editor/tool-options.tsx @@ -9,7 +9,7 @@ import { googleFontsLoader } from '@/lib/google-fonts-loader'; import { useCallback } from 'react'; export function ToolOptions() { - const { activeTool, settings, setSize, setOpacity, setHardness, setColor, setFlow } = useToolStore(); + const { activeTool, settings, setSize, setOpacity, setHardness, setColor, setFlow, setSecondaryColor, setGradientType } = useToolStore(); const { settings: shapeSettings, setShapeType } = useShapeStore(); const { selectionType, setSelectionType } = useSelectionStore(); const { @@ -49,6 +49,9 @@ export function ToolOptions() { // Fill tool const isFillTool = activeTool === 'fill'; + // Gradient tool + const isGradientTool = activeTool === 'gradient'; + // Shape tool const isShapeTool = activeTool === 'shape'; @@ -59,7 +62,7 @@ export function ToolOptions() { const isTextTool = activeTool === 'text'; // Don't show options bar if no options available - if (!isDrawingTool && !isFillTool && !isShapeTool && !isSelectionTool && !isTextTool) { + if (!isDrawingTool && !isFillTool && !isGradientTool && !isShapeTool && !isSelectionTool && !isTextTool) { return null; } @@ -202,6 +205,79 @@ export function ToolOptions() { )} + {/* Gradient Tool Options */} + {isGradientTool && ( + <> +
+ + +
+ +
+ + setColor(e.target.value)} + className="h-8 w-16 rounded border border-border cursor-pointer" + /> + setColor(e.target.value)} + className="w-24 px-2 py-1 text-xs rounded border border-border bg-background text-foreground" + /> +
+ +
+ + setSecondaryColor(e.target.value)} + className="h-8 w-16 rounded border border-border cursor-pointer" + /> + setSecondaryColor(e.target.value)} + className="w-24 px-2 py-1 text-xs rounded border border-border bg-background text-foreground" + /> +
+ +
+ + setOpacity(Number(e.target.value) / 100)} + className="w-32" + /> + + {Math.round(settings.opacity * 100)}% + +
+ + )} + {/* Shape Tool Options */} {isShapeTool && (
diff --git a/components/tools/tool-palette.tsx b/components/tools/tool-palette.tsx index 55d2549..342ed0c 100644 --- a/components/tools/tool-palette.tsx +++ b/components/tools/tool-palette.tsx @@ -7,6 +7,7 @@ import { Paintbrush, Eraser, PaintBucket, + Blend, MousePointer, Pipette, Type, @@ -21,6 +22,7 @@ const tools: { type: ToolType; icon: React.ReactNode; label: string; shortcut: s { type: 'brush', icon: , label: 'Brush', shortcut: '2' }, { type: 'eraser', icon: , label: 'Eraser', shortcut: '3' }, { type: 'fill', icon: , label: 'Fill', shortcut: '4' }, + { type: 'gradient', icon: , label: 'Gradient (Drag to create)', shortcut: 'G' }, { type: 'eyedropper', icon: , label: 'Eyedropper', shortcut: '5' }, { type: 'text', icon: , label: 'Text', shortcut: '6' }, { type: 'select', icon: , label: 'Select', shortcut: '7' }, diff --git a/lib/tool-loader.ts b/lib/tool-loader.ts index ed79eaa..dfda827 100644 --- a/lib/tool-loader.ts +++ b/lib/tool-loader.ts @@ -47,6 +47,11 @@ async function loadTool(toolKey: string): Promise { tool = new FillTool(); break; } + case 'gradient': { + const { GradientTool } = await import('@/tools/gradient-tool'); + tool = new GradientTool(); + break; + } case 'eyedropper': { const { EyedropperTool } = await import('@/tools/eyedropper-tool'); tool = new EyedropperTool(); diff --git a/store/tool-store.ts b/store/tool-store.ts index 575638c..3d69a27 100644 --- a/store/tool-store.ts +++ b/store/tool-store.ts @@ -15,6 +15,10 @@ interface ToolStore extends ToolState { setHardness: (hardness: number) => void; /** Set color */ setColor: (color: string) => void; + /** Set secondary color */ + setSecondaryColor: (color: string) => void; + /** Set gradient type */ + setGradientType: (type: 'linear' | 'radial' | 'angular') => void; /** Set flow */ setFlow: (flow: number) => void; /** Set spacing */ @@ -28,6 +32,8 @@ const DEFAULT_SETTINGS: ToolSettings = { opacity: 1, hardness: 1, color: '#000000', + secondaryColor: '#ffffff', + gradientType: 'linear', flow: 1, spacing: 0.25, }; @@ -47,6 +53,7 @@ export const useToolStore = create()( brush: 'crosshair', eraser: 'crosshair', fill: 'crosshair', + gradient: 'crosshair', eyedropper: 'crosshair', text: 'text', shape: 'crosshair', @@ -94,6 +101,18 @@ export const useToolStore = create()( })); }, + setSecondaryColor: (color) => { + set((state) => ({ + settings: { ...state.settings, secondaryColor: color }, + })); + }, + + setGradientType: (type) => { + set((state) => ({ + settings: { ...state.settings, gradientType: type }, + })); + }, + setFlow: (flow) => { set((state) => ({ settings: { ...state.settings, flow: Math.max(0, Math.min(1, flow)) }, diff --git a/tools/gradient-tool.ts b/tools/gradient-tool.ts new file mode 100644 index 0000000..3f08f48 --- /dev/null +++ b/tools/gradient-tool.ts @@ -0,0 +1,138 @@ +import { BaseTool } from './base-tool'; +import type { PointerState, ToolSettings } from '@/types'; + +export type GradientType = 'linear' | 'radial' | 'angular'; + +/** + * Gradient tool - Linear, Radial, and Angular gradients + */ +export class GradientTool extends BaseTool { + private startX = 0; + private startY = 0; + private previewCanvas: HTMLCanvasElement | null = null; + + constructor() { + super('Gradient'); + } + + onPointerDown( + pointer: PointerState, + ctx: CanvasRenderingContext2D, + settings: ToolSettings + ): void { + this.isDrawing = true; + this.startX = pointer.x; + this.startY = pointer.y; + + // Create preview canvas + this.previewCanvas = document.createElement('canvas'); + this.previewCanvas.width = ctx.canvas.width; + this.previewCanvas.height = ctx.canvas.height; + } + + onPointerMove( + pointer: PointerState, + ctx: CanvasRenderingContext2D, + settings: ToolSettings + ): void { + if (!this.isDrawing || !this.previewCanvas) return; + + const previewCtx = this.previewCanvas.getContext('2d'); + if (!previewCtx) return; + + // Clear preview + previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height); + + // Draw gradient preview + this.drawGradient( + previewCtx, + this.startX, + this.startY, + pointer.x, + pointer.y, + settings.color, + settings.secondaryColor || '#ffffff', + settings.gradientType || 'linear' + ); + + // Draw preview on main canvas (non-destructive) + const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); + ctx.putImageData(imageData, 0, 0); + ctx.globalAlpha = 0.7; + ctx.drawImage(this.previewCanvas, 0, 0); + ctx.globalAlpha = 1.0; + } + + onPointerUp( + pointer: PointerState, + ctx: CanvasRenderingContext2D, + settings: ToolSettings + ): void { + if (!this.isDrawing) return; + + // Apply final gradient + this.drawGradient( + ctx, + this.startX, + this.startY, + pointer.x, + pointer.y, + settings.color, + settings.secondaryColor || '#ffffff', + settings.gradientType || 'linear' + ); + + this.isDrawing = false; + this.previewCanvas = null; + } + + private drawGradient( + ctx: CanvasRenderingContext2D, + startX: number, + startY: number, + endX: number, + endY: number, + color1: string, + color2: string, + type: GradientType + ): void { + let gradient: CanvasGradient; + + if (type === 'linear') { + gradient = ctx.createLinearGradient(startX, startY, endX, endY); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + } else if (type === 'radial') { + const radius = Math.sqrt( + Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2) + ); + gradient = ctx.createRadialGradient(startX, startY, 0, startX, startY, radius); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + } else if (type === 'angular' && 'createConicGradient' in ctx) { + // Angular gradient using conic gradient (if supported) + const angle = Math.atan2(endY - startY, endX - startX); + // @ts-ignore - createConicGradient might not be in all browsers + gradient = ctx.createConicGradient(angle, startX, startY); + gradient.addColorStop(0, color1); + gradient.addColorStop(0.5, color2); + gradient.addColorStop(1, color1); + } else { + // Fallback to radial for angular without conic gradient support + const radius = Math.sqrt( + Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2) + ); + gradient = ctx.createRadialGradient(startX, startY, 0, startX, startY, radius); + gradient.addColorStop(0, color1); + gradient.addColorStop(1, color2); + } + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); + } + + getCursor(): string { + return 'crosshair'; + } +} diff --git a/tools/index.ts b/tools/index.ts index 447f152..94eff6a 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -3,6 +3,7 @@ export * from './pencil-tool'; export * from './brush-tool'; export * from './eraser-tool'; export * from './fill-tool'; +export * from './gradient-tool'; export * from './eyedropper-tool'; export * from './rectangular-selection-tool'; export * from './elliptical-selection-tool'; diff --git a/types/tool.ts b/types/tool.ts index d076cd7..161af02 100644 --- a/types/tool.ts +++ b/types/tool.ts @@ -10,6 +10,7 @@ export type ToolType = | 'brush' | 'eraser' | 'fill' + | 'gradient' | 'eyedropper' | 'text' | 'shape' @@ -32,6 +33,10 @@ export interface ToolSettings { hardness: number; /** Color */ color: string; + /** Secondary color (for gradients) */ + secondaryColor?: string; + /** Gradient type */ + gradientType?: 'linear' | 'radial' | 'angular'; /** Flow rate (0-1) */ flow: number; /** Spacing between brush stamps */