Files
paint-ui/components/editor/tool-options.tsx
Sebastian Krüger 841d6ca0a5 feat(phase-13): implement crop tool with visual guides and handles
Add comprehensive crop tool with visual guides, resize handles, and Apply/Cancel UI.

Features:
- Interactive crop area selection with drag-to-create
- 8 resize handles (corners and edges) for precise cropping
- Visual overlay with dimmed areas outside crop region
- Rule of thirds grid overlay for composition guidance
- Drag crop area to reposition
- Apply/Cancel buttons in tool options
- White border and handles for clear visibility

Changes:
- Created tools/crop-tool.ts with CropTool class
- Added crop tool to lib/tool-loader.ts
- Added Crop icon and button to tool palette with 'C' shortcut
- Added crop tool options UI in components/editor/tool-options.tsx
- Exported CropTool from tools/index.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 19:54:05 +01:00

514 lines
18 KiB
TypeScript

'use client';
import { useToolStore } from '@/store';
import { useShapeStore } from '@/store/shape-store';
import { useSelectionStore } from '@/store/selection-store';
import { useTextStore } from '@/store/text-store';
import { WEB_SAFE_FONTS, GOOGLE_FONTS } from '@/lib/text-utils';
import { googleFontsLoader } from '@/lib/google-fonts-loader';
import { useCallback } from 'react';
export function ToolOptions() {
const { activeTool, settings, setSize, setOpacity, setHardness, setColor, setFlow, setSecondaryColor, setGradientType } = useToolStore();
const { settings: shapeSettings, setShapeType } = useShapeStore();
const { selectionType, setSelectionType } = useSelectionStore();
const {
settings: textSettings,
setFontFamily,
setFontSize,
setFontStyle,
setFontWeight,
setAlign,
setLineHeight,
setLetterSpacing,
setColor: setTextColor,
} = useTextStore();
// Handle font change with Google Fonts loading
const handleFontChange = useCallback(
async (fontFamily: string) => {
// Check if it's a Google Font
if (GOOGLE_FONTS.includes(fontFamily as any)) {
try {
await googleFontsLoader.loadFont(fontFamily);
} catch (error) {
console.error('Failed to load Google Font:', error);
}
}
setFontFamily(fontFamily);
},
[setFontFamily]
);
// Drawing tools: brush, pencil, eraser
const isDrawingTool = ['brush', 'eraser', 'pencil'].includes(activeTool);
const showHardness = ['brush'].includes(activeTool);
const showColor = ['brush', 'pencil'].includes(activeTool);
const showFlow = ['brush'].includes(activeTool);
// Fill tool
const isFillTool = activeTool === 'fill';
// Gradient tool
const isGradientTool = activeTool === 'gradient';
// Crop tool
const isCropTool = activeTool === 'crop';
// Shape tool
const isShapeTool = activeTool === 'shape';
// Selection tool
const isSelectionTool = activeTool === 'select';
// Text tool
const isTextTool = activeTool === 'text';
// Don't show options bar if no options available
if (!isDrawingTool && !isFillTool && !isGradientTool && !isCropTool && !isShapeTool && !isSelectionTool && !isTextTool) {
return null;
}
return (
<div className="flex items-center gap-6 px-4 flex-1">
{/* Drawing Tools Options */}
{isDrawingTool && (
<>
{showColor && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
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">
Size:
</label>
<input
type="range"
min="1"
max="200"
value={settings.size}
onChange={(e) => setSize(Number(e.target.value))}
className="w-32"
/>
<span className="text-sm text-muted-foreground w-12">
{settings.size}px
</span>
</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>
{showHardness && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
Hardness:
</label>
<input
type="range"
min="0"
max="100"
value={settings.hardness * 100}
onChange={(e) => setHardness(Number(e.target.value) / 100)}
className="w-32"
/>
<span className="text-sm text-muted-foreground w-10">
{Math.round(settings.hardness * 100)}%
</span>
</div>
)}
{showFlow && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
Flow:
</label>
<input
type="range"
min="0"
max="100"
value={settings.flow * 100}
onChange={(e) => setFlow(Number(e.target.value) / 100)}
className="w-32"
/>
<span className="text-sm text-muted-foreground w-10">
{Math.round(settings.flow * 100)}%
</span>
</div>
)}
</>
)}
{/* Fill Tool Options */}
{isFillTool && (
<>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
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">
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>
</>
)}
{/* Crop Tool Options */}
{isCropTool && (
<>
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
Drag to define crop area. Click and drag handles to resize.
</div>
<button
className="px-4 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors"
title="Apply crop (Enter)"
>
Apply
</button>
<button
className="px-4 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors"
title="Cancel crop (Esc)"
>
Cancel
</button>
</div>
</>
)}
{/* 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 */}
{isShapeTool && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
Shape:
</label>
<select
value={shapeSettings.type}
onChange={(e) => setShapeType(e.target.value as any)}
className="px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground"
>
<option value="rectangle">Rectangle</option>
<option value="ellipse">Ellipse</option>
<option value="line">Line</option>
<option value="arrow">Arrow</option>
<option value="polygon">Polygon</option>
<option value="star">Star</option>
<option value="triangle">Triangle</option>
</select>
</div>
)}
{/* Selection Tool Options */}
{isSelectionTool && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
Mode:
</label>
<select
value={selectionType}
onChange={(e) => setSelectionType(e.target.value as any)}
className="px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground"
>
<option value="rectangular">Rectangular</option>
<option value="elliptical">Elliptical</option>
<option value="lasso">Lasso</option>
<option value="magic-wand">Magic Wand</option>
</select>
</div>
)}
{/* Text Tool Options */}
{isTextTool && (
<>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
Font:
</label>
<select
value={textSettings.fontFamily}
onChange={(e) => handleFontChange(e.target.value)}
className="px-3 py-1.5 text-sm rounded-md border border-border bg-background text-foreground"
style={{ fontFamily: textSettings.fontFamily }}
>
<optgroup label="Web Safe Fonts">
{WEB_SAFE_FONTS.map((font) => (
<option key={font} value={font} style={{ fontFamily: font }}>
{font}
</option>
))}
</optgroup>
<optgroup label="Google Fonts">
{GOOGLE_FONTS.map((font) => (
<option key={font} value={font} style={{ fontFamily: font }}>
{font}
</option>
))}
</optgroup>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
Size:
</label>
<input
type="number"
min="8"
max="500"
value={textSettings.fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-20 px-2 py-1 text-sm rounded border border-border bg-background text-foreground"
/>
<span className="text-sm text-muted-foreground">px</span>
</div>
{/* Font Style: Bold/Italic */}
<div className="flex items-center gap-1">
<button
onClick={() => setFontWeight(textSettings.fontWeight === 'bold' ? 'normal' : 'bold')}
className={`px-3 py-1.5 text-sm font-bold rounded-md border transition-colors ${
textSettings.fontWeight === 'bold'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-accent'
}`}
title="Bold"
>
B
</button>
<button
onClick={() => setFontStyle(textSettings.fontStyle === 'italic' ? 'normal' : 'italic')}
className={`px-3 py-1.5 text-sm italic rounded-md border transition-colors ${
textSettings.fontStyle === 'italic'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-accent'
}`}
title="Italic"
>
I
</button>
</div>
{/* Text Alignment */}
<div className="flex items-center gap-1">
<button
onClick={() => setAlign('left')}
className={`px-3 py-1.5 text-sm rounded-md border transition-colors ${
textSettings.align === 'left'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-accent'
}`}
title="Align Left"
>
</button>
<button
onClick={() => setAlign('center')}
className={`px-3 py-1.5 text-sm rounded-md border transition-colors ${
textSettings.align === 'center'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-accent'
}`}
title="Align Center"
>
</button>
<button
onClick={() => setAlign('right')}
className={`px-3 py-1.5 text-sm rounded-md border transition-colors ${
textSettings.align === 'right'
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:bg-accent'
}`}
title="Align Right"
>
</button>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
Color:
</label>
<input
type="color"
value={textSettings.color}
onChange={(e) => setTextColor(e.target.value)}
className="h-8 w-16 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={textSettings.color}
onChange={(e) => setTextColor(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">
Line Height:
</label>
<input
type="range"
min="0.5"
max="3"
step="0.1"
value={textSettings.lineHeight}
onChange={(e) => setLineHeight(Number(e.target.value))}
className="w-24"
/>
<span className="text-sm text-muted-foreground w-8">
{textSettings.lineHeight.toFixed(1)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-card-foreground whitespace-nowrap">
Letter Spacing:
</label>
<input
type="range"
min="-10"
max="50"
step="1"
value={textSettings.letterSpacing}
onChange={(e) => setLetterSpacing(Number(e.target.value))}
className="w-24"
/>
<span className="text-sm text-muted-foreground w-10">
{textSettings.letterSpacing}px
</span>
</div>
</>
)}
</div>
);
}