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:
2025-11-21 02:43:15 +01:00
parent 1d82f60182
commit 89a845feb3
12 changed files with 866 additions and 26 deletions

View File

@@ -19,6 +19,7 @@ import {
MagicWandTool, MagicWandTool,
MoveTool, MoveTool,
FreeTransformTool, FreeTransformTool,
ShapeTool,
type BaseTool, type BaseTool,
} from '@/tools'; } from '@/tools';
import type { PointerState } from '@/types'; import type { PointerState } from '@/types';
@@ -38,6 +39,7 @@ const tools: Record<string, BaseTool> = {
'magic-wand': new MagicWandTool(), 'magic-wand': new MagicWandTool(),
move: new MoveTool(), move: new MoveTool(),
transform: new FreeTransformTool(), transform: new FreeTransformTool(),
shape: new ShapeTool(),
}; };
export function CanvasWithTools() { export function CanvasWithTools() {
@@ -224,7 +226,7 @@ export function CanvasWithTools() {
} }
// Drawing tools // 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(); const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas || activeLayer.locked) return; if (!activeLayer || !activeLayer.canvas || activeLayer.locked) return;
@@ -267,7 +269,7 @@ export function CanvasWithTools() {
} }
// Drawing // Drawing
if (pointer.isDown && ['pencil', 'brush', 'eraser', 'eyedropper'].includes(activeTool)) { if (pointer.isDown && ['pencil', 'brush', 'eraser', 'eyedropper', 'shape'].includes(activeTool)) {
const activeLayer = getActiveLayer(); const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return; if (!activeLayer || !activeLayer.canvas) return;
@@ -294,7 +296,7 @@ export function CanvasWithTools() {
return; 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(); const activeLayer = getActiveLayer();
if (!activeLayer || !activeLayer.canvas) return; if (!activeLayer || !activeLayer.canvas) return;

View File

@@ -12,6 +12,7 @@ import { ColorPanel } from '@/components/colors';
import { FilterPanel } from '@/components/filters'; import { FilterPanel } from '@/components/filters';
import { SelectionPanel } from '@/components/selection'; import { SelectionPanel } from '@/components/selection';
import { TransformPanel } from '@/components/transform'; import { TransformPanel } from '@/components/transform';
import { ShapePanel } from '@/components/shapes';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useFileOperations } from '@/hooks/use-file-operations'; import { useFileOperations } from '@/hooks/use-file-operations';
import { useDragDrop } from '@/hooks/use-drag-drop'; import { useDragDrop } from '@/hooks/use-drag-drop';
@@ -187,6 +188,9 @@ export function EditorLayout() {
{/* Transform Panel */} {/* Transform Panel */}
<TransformPanel /> <TransformPanel />
{/* Shape Panel */}
<ShapePanel />
{/* Canvas area */} {/* Canvas area */}
<div className="flex-1"> <div className="flex-1">
<CanvasWithTools /> <CanvasWithTools />

View File

@@ -0,0 +1 @@
export * from './shape-panel';

View 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
View 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();
}

View File

@@ -6,3 +6,4 @@ export * from './history-store';
export * from './color-store'; export * from './color-store';
export * from './selection-store'; export * from './selection-store';
export * from './transform-store'; export * from './transform-store';
export * from './shape-store';

80
store/shape-store.ts Normal file
View 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 },
})),
}));

View File

@@ -10,3 +10,4 @@ export * from './lasso-selection-tool';
export * from './magic-wand-tool'; export * from './magic-wand-tool';
export * from './move-tool'; export * from './move-tool';
export * from './free-transform-tool'; export * from './free-transform-tool';
export * from './shape-tool';

143
tools/shape-tool.ts Normal file
View 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);
}
}

View File

@@ -5,3 +5,4 @@ export * from './history';
export * from './filter'; export * from './filter';
export * from './selection'; export * from './selection';
export * from './transform'; export * from './transform';
export * from './shape';

41
types/shape.ts Normal file
View 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;
}

View File

@@ -58,26 +58,3 @@ export interface ToolHandlers {
onActivate?: () => void; onActivate?: () => void;
onDeactivate?: () => 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;
}