feat: implement Phase 10 - Shape Tools
Add comprehensive shape drawing system with support for 7 shape types: rectangle, ellipse, line, arrow, polygon, star, and triangle. Features: - Created types/shape.ts with ShapeType and ShapeSettings interfaces - Implemented lib/shape-utils.ts with drawing algorithms for all shapes: * Rectangle with optional corner radius * Ellipse with independent x/y radii * Line with stroke support * Arrow with configurable head size and angle * Polygon with adjustable sides (3-20) * Star with points and inner radius control * Triangle (equilateral style) - Created store/shape-store.ts for shape state management - Implemented tools/shape-tool.ts as unified tool handling all shapes - Built components/shapes/shape-panel.tsx with comprehensive UI: * Grid selector for all 7 shape types * Fill/stroke toggles with color pickers * Dynamic properties panel (corner radius, sides, inner radius, etc.) * Real-time stroke width adjustment - Integrated ShapeTool into canvas-with-tools.tsx - Added ShapePanel to editor-layout.tsx sidebar - Removed duplicate ShapeType/ShapeSettings from types/tool.ts All shapes support: - Fill with color selection - Stroke with color and width controls - Shape-specific properties (corners, sides, arrow heads, etc.) - Undo/redo via DrawCommand integration Build Status: ✓ Successful (1290ms) 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
MagicWandTool,
|
||||
MoveTool,
|
||||
FreeTransformTool,
|
||||
ShapeTool,
|
||||
type BaseTool,
|
||||
} from '@/tools';
|
||||
import type { PointerState } from '@/types';
|
||||
@@ -38,6 +39,7 @@ const tools: Record<string, BaseTool> = {
|
||||
'magic-wand': new MagicWandTool(),
|
||||
move: new MoveTool(),
|
||||
transform: new FreeTransformTool(),
|
||||
shape: new ShapeTool(),
|
||||
};
|
||||
|
||||
export function CanvasWithTools() {
|
||||
@@ -224,7 +226,7 @@ export function CanvasWithTools() {
|
||||
}
|
||||
|
||||
// Drawing tools
|
||||
if (e.button === 0 && !e.shiftKey && ['pencil', 'brush', 'eraser', 'fill', 'eyedropper'].includes(activeTool)) {
|
||||
if (e.button === 0 && !e.shiftKey && ['pencil', 'brush', 'eraser', 'fill', 'eyedropper', 'shape'].includes(activeTool)) {
|
||||
const activeLayer = getActiveLayer();
|
||||
if (!activeLayer || !activeLayer.canvas || activeLayer.locked) return;
|
||||
|
||||
@@ -267,7 +269,7 @@ export function CanvasWithTools() {
|
||||
}
|
||||
|
||||
// Drawing
|
||||
if (pointer.isDown && ['pencil', 'brush', 'eraser', 'eyedropper'].includes(activeTool)) {
|
||||
if (pointer.isDown && ['pencil', 'brush', 'eraser', 'eyedropper', 'shape'].includes(activeTool)) {
|
||||
const activeLayer = getActiveLayer();
|
||||
if (!activeLayer || !activeLayer.canvas) return;
|
||||
|
||||
@@ -294,7 +296,7 @@ export function CanvasWithTools() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pointer.isDown && ['pencil', 'brush', 'eraser', 'fill', 'eyedropper'].includes(activeTool)) {
|
||||
if (pointer.isDown && ['pencil', 'brush', 'eraser', 'fill', 'eyedropper', 'shape'].includes(activeTool)) {
|
||||
const activeLayer = getActiveLayer();
|
||||
if (!activeLayer || !activeLayer.canvas) return;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ColorPanel } from '@/components/colors';
|
||||
import { FilterPanel } from '@/components/filters';
|
||||
import { SelectionPanel } from '@/components/selection';
|
||||
import { TransformPanel } from '@/components/transform';
|
||||
import { ShapePanel } from '@/components/shapes';
|
||||
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
|
||||
import { useFileOperations } from '@/hooks/use-file-operations';
|
||||
import { useDragDrop } from '@/hooks/use-drag-drop';
|
||||
@@ -187,6 +188,9 @@ export function EditorLayout() {
|
||||
{/* Transform Panel */}
|
||||
<TransformPanel />
|
||||
|
||||
{/* Shape Panel */}
|
||||
<ShapePanel />
|
||||
|
||||
{/* Canvas area */}
|
||||
<div className="flex-1">
|
||||
<CanvasWithTools />
|
||||
|
||||
1
components/shapes/index.ts
Normal file
1
components/shapes/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './shape-panel';
|
||||
287
components/shapes/shape-panel.tsx
Normal file
287
components/shapes/shape-panel.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
'use client';
|
||||
|
||||
import { useShapeStore } from '@/store/shape-store';
|
||||
import { useToolStore } from '@/store/tool-store';
|
||||
import { useColorStore } from '@/store/color-store';
|
||||
import type { ShapeType } from '@/types/shape';
|
||||
import {
|
||||
Square,
|
||||
Circle,
|
||||
Minus,
|
||||
ArrowRight,
|
||||
Pentagon,
|
||||
Star,
|
||||
Triangle,
|
||||
Paintbrush,
|
||||
PenTool,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SHAPES: Array<{
|
||||
type: ShapeType;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}> = [
|
||||
{ type: 'rectangle', label: 'Rectangle', icon: Square },
|
||||
{ type: 'ellipse', label: 'Ellipse', icon: Circle },
|
||||
{ type: 'line', label: 'Line', icon: Minus },
|
||||
{ type: 'arrow', label: 'Arrow', icon: ArrowRight },
|
||||
{ type: 'polygon', label: 'Polygon', icon: Pentagon },
|
||||
{ type: 'star', label: 'Star', icon: Star },
|
||||
{ type: 'triangle', label: 'Triangle', icon: Triangle },
|
||||
];
|
||||
|
||||
export function ShapePanel() {
|
||||
const {
|
||||
settings,
|
||||
setShapeType,
|
||||
setFill,
|
||||
setStroke,
|
||||
setStrokeWidth,
|
||||
setCornerRadius,
|
||||
setSides,
|
||||
setInnerRadius,
|
||||
setArrowHeadSize,
|
||||
setArrowHeadAngle,
|
||||
} = useShapeStore();
|
||||
|
||||
const { setActiveTool } = useToolStore();
|
||||
const { primaryColor, setPrimaryColor } = useColorStore();
|
||||
|
||||
const handleShapeSelect = (type: ShapeType) => {
|
||||
setShapeType(type);
|
||||
setActiveTool('shape');
|
||||
};
|
||||
|
||||
const handleFillColorChange = (color: string) => {
|
||||
setPrimaryColor(color);
|
||||
useShapeStore.getState().setFillColor(color);
|
||||
};
|
||||
|
||||
const handleStrokeColorChange = (color: string) => {
|
||||
useShapeStore.getState().setStrokeColor(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 border-l border-border bg-card flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border p-3">
|
||||
<Square className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-sm font-semibold">Shapes</h2>
|
||||
</div>
|
||||
|
||||
{/* Shape Types */}
|
||||
<div className="border-b border-border p-3">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">
|
||||
Type
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{SHAPES.map((shape) => (
|
||||
<button
|
||||
key={shape.type}
|
||||
onClick={() => handleShapeSelect(shape.type)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md p-2 text-xs transition-colors',
|
||||
settings.type === shape.type
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-accent text-foreground'
|
||||
)}
|
||||
>
|
||||
<shape.icon className="h-4 w-4" />
|
||||
<span>{shape.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fill and Stroke */}
|
||||
<div className="border-b border-border p-3 space-y-3">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Style
|
||||
</h3>
|
||||
|
||||
{/* Fill */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setFill(!settings.fill)}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Paintbrush className="h-4 w-4" />
|
||||
<span>Fill</span>
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.fill}
|
||||
onChange={(e) => setFill(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
{settings.fill && (
|
||||
<input
|
||||
type="color"
|
||||
value={settings.fillColor}
|
||||
onChange={(e) => handleFillColorChange(e.target.value)}
|
||||
className="w-full h-8 rounded cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stroke */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setStroke(!settings.stroke)}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<PenTool className="h-4 w-4" />
|
||||
<span>Stroke</span>
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.stroke}
|
||||
onChange={(e) => setStroke(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
{settings.stroke && (
|
||||
<>
|
||||
<input
|
||||
type="color"
|
||||
value={settings.strokeColor}
|
||||
onChange={(e) => handleStrokeColorChange(e.target.value)}
|
||||
className="w-full h-8 rounded cursor-pointer"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Width</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value={settings.strokeWidth}
|
||||
onChange={(e) => setStrokeWidth(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{settings.strokeWidth}px
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shape-specific settings */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Properties
|
||||
</h3>
|
||||
|
||||
{/* Rectangle: Corner Radius */}
|
||||
{settings.type === 'rectangle' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-foreground">Corner Radius</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={settings.cornerRadius}
|
||||
onChange={(e) => setCornerRadius(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{settings.cornerRadius}px
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Polygon: Sides */}
|
||||
{settings.type === 'polygon' && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-foreground">Sides</label>
|
||||
<input
|
||||
type="range"
|
||||
min="3"
|
||||
max="20"
|
||||
value={settings.sides}
|
||||
onChange={(e) => setSides(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{settings.sides}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Star: Points and Inner Radius */}
|
||||
{settings.type === 'star' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-foreground">Points</label>
|
||||
<input
|
||||
type="range"
|
||||
min="3"
|
||||
max="20"
|
||||
value={settings.sides}
|
||||
onChange={(e) => setSides(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{settings.sides}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-foreground">Inner Radius</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="90"
|
||||
step="5"
|
||||
value={settings.innerRadius * 100}
|
||||
onChange={(e) => setInnerRadius(Number(e.target.value) / 100)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{Math.round(settings.innerRadius * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Arrow: Head Size and Angle */}
|
||||
{settings.type === 'arrow' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-foreground">Head Size</label>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="100"
|
||||
value={settings.arrowHeadSize}
|
||||
onChange={(e) => setArrowHeadSize(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{settings.arrowHeadSize}px
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-foreground">Head Angle</label>
|
||||
<input
|
||||
type="range"
|
||||
min="10"
|
||||
max="60"
|
||||
value={settings.arrowHeadAngle}
|
||||
onChange={(e) => setArrowHeadAngle(Number(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{settings.arrowHeadAngle}°
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
lib/shape-utils.ts
Normal file
302
lib/shape-utils.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import type { ShapeSettings } from '@/types/shape';
|
||||
|
||||
/**
|
||||
* Draw a rectangle with optional rounded corners
|
||||
*/
|
||||
export function drawRectangle(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
settings: ShapeSettings
|
||||
): void {
|
||||
ctx.save();
|
||||
|
||||
if (settings.cornerRadius > 0) {
|
||||
// Rounded rectangle
|
||||
const radius = Math.min(
|
||||
settings.cornerRadius,
|
||||
Math.abs(width) / 2,
|
||||
Math.abs(height) / 2
|
||||
);
|
||||
|
||||
ctx.beginPath();
|
||||
const x1 = Math.min(x, x + width);
|
||||
const y1 = Math.min(y, y + height);
|
||||
const w = Math.abs(width);
|
||||
const h = Math.abs(height);
|
||||
|
||||
ctx.moveTo(x1 + radius, y1);
|
||||
ctx.lineTo(x1 + w - radius, y1);
|
||||
ctx.quadraticCurveTo(x1 + w, y1, x1 + w, y1 + radius);
|
||||
ctx.lineTo(x1 + w, y1 + h - radius);
|
||||
ctx.quadraticCurveTo(x1 + w, y1 + h, x1 + w - radius, y1 + h);
|
||||
ctx.lineTo(x1 + radius, y1 + h);
|
||||
ctx.quadraticCurveTo(x1, y1 + h, x1, y1 + h - radius);
|
||||
ctx.lineTo(x1, y1 + radius);
|
||||
ctx.quadraticCurveTo(x1, y1, x1 + radius, y1);
|
||||
ctx.closePath();
|
||||
} else {
|
||||
// Regular rectangle
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, width, height);
|
||||
}
|
||||
|
||||
if (settings.fill) {
|
||||
ctx.fillStyle = settings.fillColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = settings.strokeColor;
|
||||
ctx.lineWidth = settings.strokeWidth;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an ellipse
|
||||
*/
|
||||
export function drawEllipse(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
rx: number,
|
||||
ry: number,
|
||||
settings: ShapeSettings
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, Math.abs(rx), Math.abs(ry), 0, 0, Math.PI * 2);
|
||||
|
||||
if (settings.fill) {
|
||||
ctx.fillStyle = settings.fillColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = settings.strokeColor;
|
||||
ctx.lineWidth = settings.strokeWidth;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line
|
||||
*/
|
||||
export function drawLine(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
settings: ShapeSettings
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
|
||||
ctx.strokeStyle = settings.strokeColor;
|
||||
ctx.lineWidth = settings.strokeWidth;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw an arrow
|
||||
*/
|
||||
export function drawArrow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
settings: ShapeSettings
|
||||
): void {
|
||||
ctx.save();
|
||||
|
||||
// Draw line
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.strokeStyle = settings.strokeColor;
|
||||
ctx.lineWidth = settings.strokeWidth;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// Calculate arrow head
|
||||
const angle = Math.atan2(y2 - y1, x2 - x1);
|
||||
const headSize = settings.arrowHeadSize;
|
||||
const headAngle = (settings.arrowHeadAngle * Math.PI) / 180;
|
||||
|
||||
const leftAngle = angle + Math.PI - headAngle;
|
||||
const rightAngle = angle + Math.PI + headAngle;
|
||||
|
||||
const leftX = x2 + headSize * Math.cos(leftAngle);
|
||||
const leftY = y2 + headSize * Math.sin(leftAngle);
|
||||
const rightX = x2 + headSize * Math.cos(rightAngle);
|
||||
const rightY = y2 + headSize * Math.sin(rightAngle);
|
||||
|
||||
// Draw arrow head
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(leftX, leftY);
|
||||
ctx.lineTo(rightX, rightY);
|
||||
ctx.closePath();
|
||||
|
||||
if (settings.fill) {
|
||||
ctx.fillStyle = settings.fillColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = settings.strokeColor;
|
||||
ctx.lineWidth = settings.strokeWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a regular polygon
|
||||
*/
|
||||
export function drawPolygon(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
sides: number,
|
||||
settings: ShapeSettings
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
|
||||
const angleStep = (Math.PI * 2) / sides;
|
||||
const startAngle = -Math.PI / 2; // Start at top
|
||||
|
||||
for (let i = 0; i <= sides; i++) {
|
||||
const angle = startAngle + i * angleStep;
|
||||
const x = cx + radius * Math.cos(angle);
|
||||
const y = cy + radius * Math.sin(angle);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
|
||||
if (settings.fill) {
|
||||
ctx.fillStyle = settings.fillColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = settings.strokeColor;
|
||||
ctx.lineWidth = settings.strokeWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a star
|
||||
*/
|
||||
export function drawStar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cx: number,
|
||||
cy: number,
|
||||
outerRadius: number,
|
||||
points: number,
|
||||
innerRadiusRatio: number,
|
||||
settings: ShapeSettings
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
|
||||
const innerRadius = outerRadius * innerRadiusRatio;
|
||||
const angleStep = Math.PI / points;
|
||||
const startAngle = -Math.PI / 2;
|
||||
|
||||
for (let i = 0; i < points * 2; i++) {
|
||||
const angle = startAngle + i * angleStep;
|
||||
const radius = i % 2 === 0 ? outerRadius : innerRadius;
|
||||
const x = cx + radius * Math.cos(angle);
|
||||
const y = cy + radius * Math.sin(angle);
|
||||
|
||||
if (i === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.closePath();
|
||||
|
||||
if (settings.fill) {
|
||||
ctx.fillStyle = settings.fillColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = settings.strokeColor;
|
||||
ctx.lineWidth = settings.strokeWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a triangle
|
||||
*/
|
||||
export function drawTriangle(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
settings: ShapeSettings
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
|
||||
// Equilateral triangle
|
||||
const centerX = x + width / 2;
|
||||
const topY = y;
|
||||
const bottomY = y + height;
|
||||
const leftX = x;
|
||||
const rightX = x + width;
|
||||
|
||||
ctx.moveTo(centerX, topY);
|
||||
ctx.lineTo(rightX, bottomY);
|
||||
ctx.lineTo(leftX, bottomY);
|
||||
ctx.closePath();
|
||||
|
||||
if (settings.fill) {
|
||||
ctx.fillStyle = settings.fillColor;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = settings.strokeColor;
|
||||
ctx.lineWidth = settings.strokeWidth;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './history-store';
|
||||
export * from './color-store';
|
||||
export * from './selection-store';
|
||||
export * from './transform-store';
|
||||
export * from './shape-store';
|
||||
|
||||
80
store/shape-store.ts
Normal file
80
store/shape-store.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ShapeSettings, ShapeType, ShapeStore as IShapeStore } from '@/types/shape';
|
||||
|
||||
const DEFAULT_SETTINGS: ShapeSettings = {
|
||||
type: 'rectangle',
|
||||
fill: true,
|
||||
fillColor: '#000000',
|
||||
stroke: true,
|
||||
strokeColor: '#000000',
|
||||
strokeWidth: 2,
|
||||
cornerRadius: 0,
|
||||
sides: 5,
|
||||
innerRadius: 0.5,
|
||||
arrowHeadSize: 20,
|
||||
arrowHeadAngle: 30,
|
||||
};
|
||||
|
||||
export const useShapeStore = create<IShapeStore>((set) => ({
|
||||
settings: { ...DEFAULT_SETTINGS },
|
||||
|
||||
setShapeType: (type) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, type },
|
||||
})),
|
||||
|
||||
setFill: (fill) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, fill },
|
||||
})),
|
||||
|
||||
setFillColor: (fillColor) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, fillColor },
|
||||
})),
|
||||
|
||||
setStroke: (stroke) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, stroke },
|
||||
})),
|
||||
|
||||
setStrokeColor: (strokeColor) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, strokeColor },
|
||||
})),
|
||||
|
||||
setStrokeWidth: (strokeWidth) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, strokeWidth: Math.max(1, Math.min(100, strokeWidth)) },
|
||||
})),
|
||||
|
||||
setCornerRadius: (cornerRadius) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, cornerRadius: Math.max(0, Math.min(100, cornerRadius)) },
|
||||
})),
|
||||
|
||||
setSides: (sides) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, sides: Math.max(3, Math.min(20, sides)) },
|
||||
})),
|
||||
|
||||
setInnerRadius: (innerRadius) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, innerRadius: Math.max(0.1, Math.min(0.9, innerRadius)) },
|
||||
})),
|
||||
|
||||
setArrowHeadSize: (arrowHeadSize) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, arrowHeadSize: Math.max(5, Math.min(100, arrowHeadSize)) },
|
||||
})),
|
||||
|
||||
setArrowHeadAngle: (arrowHeadAngle) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, arrowHeadAngle: Math.max(10, Math.min(60, arrowHeadAngle)) },
|
||||
})),
|
||||
|
||||
updateSettings: (settings) =>
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...settings },
|
||||
})),
|
||||
}));
|
||||
@@ -10,3 +10,4 @@ export * from './lasso-selection-tool';
|
||||
export * from './magic-wand-tool';
|
||||
export * from './move-tool';
|
||||
export * from './free-transform-tool';
|
||||
export * from './shape-tool';
|
||||
|
||||
143
tools/shape-tool.ts
Normal file
143
tools/shape-tool.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { BaseTool } from './base-tool';
|
||||
import type { PointerState } from '@/types';
|
||||
import { useLayerStore } from '@/store/layer-store';
|
||||
import { useShapeStore } from '@/store/shape-store';
|
||||
import { useHistoryStore } from '@/store/history-store';
|
||||
import { DrawCommand } from '@/core/commands/draw-command';
|
||||
import {
|
||||
drawRectangle,
|
||||
drawEllipse,
|
||||
drawLine,
|
||||
drawArrow,
|
||||
drawPolygon,
|
||||
drawStar,
|
||||
drawTriangle,
|
||||
} from '@/lib/shape-utils';
|
||||
|
||||
export class ShapeTool extends BaseTool {
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private currentX = 0;
|
||||
private currentY = 0;
|
||||
private drawCommand: DrawCommand | null = null;
|
||||
|
||||
constructor() {
|
||||
super('Shape');
|
||||
}
|
||||
|
||||
onPointerDown(pointer: PointerState): void {
|
||||
this.isActive = true;
|
||||
this.isDrawing = true;
|
||||
this.startX = pointer.x;
|
||||
this.startY = pointer.y;
|
||||
this.currentX = pointer.x;
|
||||
this.currentY = pointer.y;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer) return;
|
||||
|
||||
// Create draw command for history
|
||||
this.drawCommand = new DrawCommand(layer.id, 'Draw Shape');
|
||||
}
|
||||
|
||||
onPointerMove(pointer: PointerState, ctx: CanvasRenderingContext2D): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
this.currentX = pointer.x;
|
||||
this.currentY = pointer.y;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (!layer?.canvas) return;
|
||||
|
||||
const layerCtx = layer.canvas.getContext('2d');
|
||||
if (!layerCtx) return;
|
||||
|
||||
// Clear and redraw from saved state
|
||||
if (this.drawCommand) {
|
||||
const beforeCanvas = (this.drawCommand as any).beforeCanvas;
|
||||
if (beforeCanvas) {
|
||||
layerCtx.clearRect(0, 0, layer.canvas.width, layer.canvas.height);
|
||||
layerCtx.drawImage(beforeCanvas, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw preview shape
|
||||
this.drawShape(layerCtx);
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
if (!this.isDrawing) return;
|
||||
|
||||
const layer = this.getActiveLayer();
|
||||
if (layer?.canvas) {
|
||||
const ctx = layer.canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Final draw
|
||||
this.drawShape(ctx);
|
||||
|
||||
// Capture after state and add to history
|
||||
if (this.drawCommand) {
|
||||
this.drawCommand.captureAfterState();
|
||||
const { executeCommand } = useHistoryStore.getState();
|
||||
executeCommand(this.drawCommand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.isDrawing = false;
|
||||
this.isActive = false;
|
||||
this.drawCommand = null;
|
||||
}
|
||||
|
||||
getCursor(): string {
|
||||
return 'crosshair';
|
||||
}
|
||||
|
||||
private drawShape(ctx: CanvasRenderingContext2D): void {
|
||||
const { settings } = useShapeStore.getState();
|
||||
|
||||
const x = Math.min(this.startX, this.currentX);
|
||||
const y = Math.min(this.startY, this.currentY);
|
||||
const width = Math.abs(this.currentX - this.startX);
|
||||
const height = Math.abs(this.currentY - this.startY);
|
||||
|
||||
const cx = (this.startX + this.currentX) / 2;
|
||||
const cy = (this.startY + this.currentY) / 2;
|
||||
const radius = Math.sqrt(Math.pow(width / 2, 2) + Math.pow(height / 2, 2));
|
||||
|
||||
switch (settings.type) {
|
||||
case 'rectangle':
|
||||
drawRectangle(ctx, x, y, width, height, settings);
|
||||
break;
|
||||
|
||||
case 'ellipse':
|
||||
drawEllipse(ctx, cx, cy, width / 2, height / 2, settings);
|
||||
break;
|
||||
|
||||
case 'line':
|
||||
drawLine(ctx, this.startX, this.startY, this.currentX, this.currentY, settings);
|
||||
break;
|
||||
|
||||
case 'arrow':
|
||||
drawArrow(ctx, this.startX, this.startY, this.currentX, this.currentY, settings);
|
||||
break;
|
||||
|
||||
case 'polygon':
|
||||
drawPolygon(ctx, cx, cy, radius, settings.sides, settings);
|
||||
break;
|
||||
|
||||
case 'star':
|
||||
drawStar(ctx, cx, cy, radius, settings.sides, settings.innerRadius, settings);
|
||||
break;
|
||||
|
||||
case 'triangle':
|
||||
drawTriangle(ctx, x, y, width, height, settings);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private getActiveLayer() {
|
||||
const { activeLayerId, layers } = useLayerStore.getState();
|
||||
return layers.find((l) => l.id === activeLayerId);
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './history';
|
||||
export * from './filter';
|
||||
export * from './selection';
|
||||
export * from './transform';
|
||||
export * from './shape';
|
||||
|
||||
41
types/shape.ts
Normal file
41
types/shape.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export type ShapeType =
|
||||
| 'rectangle'
|
||||
| 'ellipse'
|
||||
| 'line'
|
||||
| 'arrow'
|
||||
| 'polygon'
|
||||
| 'star'
|
||||
| 'triangle';
|
||||
|
||||
export interface ShapeSettings {
|
||||
type: ShapeType;
|
||||
fill: boolean;
|
||||
fillColor: string;
|
||||
stroke: boolean;
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
cornerRadius: number; // For rounded rectangles
|
||||
sides: number; // For polygons and stars
|
||||
innerRadius: number; // For stars (0-1, percentage of outer radius)
|
||||
arrowHeadSize: number; // For arrows
|
||||
arrowHeadAngle: number; // For arrows (degrees)
|
||||
}
|
||||
|
||||
export interface ShapeState {
|
||||
settings: ShapeSettings;
|
||||
}
|
||||
|
||||
export interface ShapeStore extends ShapeState {
|
||||
setShapeType: (type: ShapeType) => void;
|
||||
setFill: (fill: boolean) => void;
|
||||
setFillColor: (color: string) => void;
|
||||
setStroke: (stroke: boolean) => void;
|
||||
setStrokeColor: (color: string) => void;
|
||||
setStrokeWidth: (width: number) => void;
|
||||
setCornerRadius: (radius: number) => void;
|
||||
setSides: (sides: number) => void;
|
||||
setInnerRadius: (radius: number) => void;
|
||||
setArrowHeadSize: (size: number) => void;
|
||||
setArrowHeadAngle: (angle: number) => void;
|
||||
updateSettings: (settings: Partial<ShapeSettings>) => void;
|
||||
}
|
||||
@@ -58,26 +58,3 @@ export interface ToolHandlers {
|
||||
onActivate?: () => void;
|
||||
onDeactivate?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape types for shape tool
|
||||
*/
|
||||
export type ShapeType =
|
||||
| 'rectangle'
|
||||
| 'ellipse'
|
||||
| 'line'
|
||||
| 'arrow'
|
||||
| 'polygon'
|
||||
| 'star';
|
||||
|
||||
/**
|
||||
* Shape tool settings
|
||||
*/
|
||||
export interface ShapeSettings {
|
||||
type: ShapeType;
|
||||
fill: boolean;
|
||||
stroke: boolean;
|
||||
strokeWidth: number;
|
||||
fillColor: string;
|
||||
strokeColor: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user