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

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