feat(phase-13): implement gradient tool with linear, radial, and angular modes
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 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import { googleFontsLoader } from '@/lib/google-fonts-loader';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
export function ToolOptions() {
|
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 { settings: shapeSettings, setShapeType } = useShapeStore();
|
||||||
const { selectionType, setSelectionType } = useSelectionStore();
|
const { selectionType, setSelectionType } = useSelectionStore();
|
||||||
const {
|
const {
|
||||||
@@ -49,6 +49,9 @@ export function ToolOptions() {
|
|||||||
// Fill tool
|
// Fill tool
|
||||||
const isFillTool = activeTool === 'fill';
|
const isFillTool = activeTool === 'fill';
|
||||||
|
|
||||||
|
// Gradient tool
|
||||||
|
const isGradientTool = activeTool === 'gradient';
|
||||||
|
|
||||||
// Shape tool
|
// Shape tool
|
||||||
const isShapeTool = activeTool === 'shape';
|
const isShapeTool = activeTool === 'shape';
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@ export function ToolOptions() {
|
|||||||
const isTextTool = activeTool === 'text';
|
const isTextTool = activeTool === 'text';
|
||||||
|
|
||||||
// Don't show options bar if no options available
|
// Don't show options bar if no options available
|
||||||
if (!isDrawingTool && !isFillTool && !isShapeTool && !isSelectionTool && !isTextTool) {
|
if (!isDrawingTool && !isFillTool && !isGradientTool && !isShapeTool && !isSelectionTool && !isTextTool) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +205,79 @@ export function ToolOptions() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Gradient Tool Options */}
|
||||||
|
{isGradientTool && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
|
||||||
|
Type:
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={settings.gradientType || 'linear'}
|
||||||
|
onChange={(e) => setGradientType(e.target.value as 'linear' | 'radial' | 'angular')}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground"
|
||||||
|
>
|
||||||
|
<option value="linear">Linear</option>
|
||||||
|
<option value="radial">Radial</option>
|
||||||
|
<option value="angular">Angular</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
|
||||||
|
Start Color:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={settings.color}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
className="h-8 w-16 rounded border border-border cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.color}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
className="w-24 px-2 py-1 text-xs rounded border border-border bg-background text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
|
||||||
|
End Color:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={settings.secondaryColor || '#ffffff'}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
className="h-8 w-16 rounded border border-border cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.secondaryColor || '#ffffff'}
|
||||||
|
onChange={(e) => setSecondaryColor(e.target.value)}
|
||||||
|
className="w-24 px-2 py-1 text-xs rounded border border-border bg-background text-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
|
||||||
|
Opacity:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={settings.opacity * 100}
|
||||||
|
onChange={(e) => setOpacity(Number(e.target.value) / 100)}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground w-10">
|
||||||
|
{Math.round(settings.opacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Shape Tool Options */}
|
{/* Shape Tool Options */}
|
||||||
{isShapeTool && (
|
{isShapeTool && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Paintbrush,
|
Paintbrush,
|
||||||
Eraser,
|
Eraser,
|
||||||
PaintBucket,
|
PaintBucket,
|
||||||
|
Blend,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
Pipette,
|
Pipette,
|
||||||
Type,
|
Type,
|
||||||
@@ -21,6 +22,7 @@ const tools: { type: ToolType; icon: React.ReactNode; label: string; shortcut: s
|
|||||||
{ type: 'brush', icon: <Paintbrush className="h-5 w-5" />, label: 'Brush', shortcut: '2' },
|
{ type: 'brush', icon: <Paintbrush className="h-5 w-5" />, label: 'Brush', shortcut: '2' },
|
||||||
{ type: 'eraser', icon: <Eraser className="h-5 w-5" />, label: 'Eraser', shortcut: '3' },
|
{ type: 'eraser', icon: <Eraser className="h-5 w-5" />, label: 'Eraser', shortcut: '3' },
|
||||||
{ type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill', shortcut: '4' },
|
{ type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill', shortcut: '4' },
|
||||||
|
{ type: 'gradient', icon: <Blend className="h-5 w-5" />, label: 'Gradient (Drag to create)', shortcut: 'G' },
|
||||||
{ type: 'eyedropper', icon: <Pipette className="h-5 w-5" />, label: 'Eyedropper', shortcut: '5' },
|
{ type: 'eyedropper', icon: <Pipette className="h-5 w-5" />, label: 'Eyedropper', shortcut: '5' },
|
||||||
{ type: 'text', icon: <Type className="h-5 w-5" />, label: 'Text', shortcut: '6' },
|
{ type: 'text', icon: <Type className="h-5 w-5" />, label: 'Text', shortcut: '6' },
|
||||||
{ type: 'select', icon: <MousePointer className="h-5 w-5" />, label: 'Select', shortcut: '7' },
|
{ type: 'select', icon: <MousePointer className="h-5 w-5" />, label: 'Select', shortcut: '7' },
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ async function loadTool(toolKey: string): Promise<BaseTool> {
|
|||||||
tool = new FillTool();
|
tool = new FillTool();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'gradient': {
|
||||||
|
const { GradientTool } = await import('@/tools/gradient-tool');
|
||||||
|
tool = new GradientTool();
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'eyedropper': {
|
case 'eyedropper': {
|
||||||
const { EyedropperTool } = await import('@/tools/eyedropper-tool');
|
const { EyedropperTool } = await import('@/tools/eyedropper-tool');
|
||||||
tool = new EyedropperTool();
|
tool = new EyedropperTool();
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ interface ToolStore extends ToolState {
|
|||||||
setHardness: (hardness: number) => void;
|
setHardness: (hardness: number) => void;
|
||||||
/** Set color */
|
/** Set color */
|
||||||
setColor: (color: string) => void;
|
setColor: (color: string) => void;
|
||||||
|
/** Set secondary color */
|
||||||
|
setSecondaryColor: (color: string) => void;
|
||||||
|
/** Set gradient type */
|
||||||
|
setGradientType: (type: 'linear' | 'radial' | 'angular') => void;
|
||||||
/** Set flow */
|
/** Set flow */
|
||||||
setFlow: (flow: number) => void;
|
setFlow: (flow: number) => void;
|
||||||
/** Set spacing */
|
/** Set spacing */
|
||||||
@@ -28,6 +32,8 @@ const DEFAULT_SETTINGS: ToolSettings = {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
hardness: 1,
|
hardness: 1,
|
||||||
color: '#000000',
|
color: '#000000',
|
||||||
|
secondaryColor: '#ffffff',
|
||||||
|
gradientType: 'linear',
|
||||||
flow: 1,
|
flow: 1,
|
||||||
spacing: 0.25,
|
spacing: 0.25,
|
||||||
};
|
};
|
||||||
@@ -47,6 +53,7 @@ export const useToolStore = create<ToolStore>()(
|
|||||||
brush: 'crosshair',
|
brush: 'crosshair',
|
||||||
eraser: 'crosshair',
|
eraser: 'crosshair',
|
||||||
fill: 'crosshair',
|
fill: 'crosshair',
|
||||||
|
gradient: 'crosshair',
|
||||||
eyedropper: 'crosshair',
|
eyedropper: 'crosshair',
|
||||||
text: 'text',
|
text: 'text',
|
||||||
shape: 'crosshair',
|
shape: 'crosshair',
|
||||||
@@ -94,6 +101,18 @@ export const useToolStore = create<ToolStore>()(
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSecondaryColor: (color) => {
|
||||||
|
set((state) => ({
|
||||||
|
settings: { ...state.settings, secondaryColor: color },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
setGradientType: (type) => {
|
||||||
|
set((state) => ({
|
||||||
|
settings: { ...state.settings, gradientType: type },
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
setFlow: (flow) => {
|
setFlow: (flow) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
settings: { ...state.settings, flow: Math.max(0, Math.min(1, flow)) },
|
settings: { ...state.settings, flow: Math.max(0, Math.min(1, flow)) },
|
||||||
|
|||||||
138
tools/gradient-tool.ts
Normal file
138
tools/gradient-tool.ts
Normal file
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export * from './pencil-tool';
|
|||||||
export * from './brush-tool';
|
export * from './brush-tool';
|
||||||
export * from './eraser-tool';
|
export * from './eraser-tool';
|
||||||
export * from './fill-tool';
|
export * from './fill-tool';
|
||||||
|
export * from './gradient-tool';
|
||||||
export * from './eyedropper-tool';
|
export * from './eyedropper-tool';
|
||||||
export * from './rectangular-selection-tool';
|
export * from './rectangular-selection-tool';
|
||||||
export * from './elliptical-selection-tool';
|
export * from './elliptical-selection-tool';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type ToolType =
|
|||||||
| 'brush'
|
| 'brush'
|
||||||
| 'eraser'
|
| 'eraser'
|
||||||
| 'fill'
|
| 'fill'
|
||||||
|
| 'gradient'
|
||||||
| 'eyedropper'
|
| 'eyedropper'
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'shape'
|
| 'shape'
|
||||||
@@ -32,6 +33,10 @@ export interface ToolSettings {
|
|||||||
hardness: number;
|
hardness: number;
|
||||||
/** Color */
|
/** Color */
|
||||||
color: string;
|
color: string;
|
||||||
|
/** Secondary color (for gradients) */
|
||||||
|
secondaryColor?: string;
|
||||||
|
/** Gradient type */
|
||||||
|
gradientType?: 'linear' | 'radial' | 'angular';
|
||||||
/** Flow rate (0-1) */
|
/** Flow rate (0-1) */
|
||||||
flow: number;
|
flow: number;
|
||||||
/** Spacing between brush stamps */
|
/** Spacing between brush stamps */
|
||||||
|
|||||||
Reference in New Issue
Block a user