feat: implement comprehensive text tool (Phase 11)

Add complete text rendering system with the following features:

**Text Tool Core:**
- TextTool class with click-to-place text functionality
- Text cursor and pointer event handling
- Integration with DrawCommand for undo/redo support

**Text Rendering:**
- Multi-line text support with line height control
- Custom letter spacing
- Text alignment (left/center/right)
- Font families: 13 web-safe fonts + 14 popular Google Fonts
- Dynamic Google Font loading via Web Font Loader API
- Font styles (normal/italic) and weights (100-900)

**Text Dialog UI:**
- Full-featured text editor dialog
- Live preview of text with all formatting
- Font family selection (web-safe + Google Fonts)
- Font size (8-500px), style, and weight controls
- Color picker with hex input
- Text alignment options
- Line height slider (0.5-3x)
- Letter spacing slider (-10 to 50px)
- Multi-line text input with textarea

**State Management:**
- text-store with Zustand + persist middleware
- All text settings preserved across sessions
- Dialog state management (open/close)
- Click position tracking

**Integration:**
- Added text tool to tool palette with Type icon
- Registered TextTool in canvas tool system
- Added TextDialog to editor layout
- Full type safety with TypeScript interfaces

**Undoable:**
- Text rendering fully integrated with command pattern
- Each text insertion creates single undo point
- Proper before/after state capture

This completes Phase 11 of the implementation plan, marking
the transition from MVP to a fully-featured image editor.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-21 09:45:05 +01:00
parent e463d2e317
commit fea87d3a1e
11 changed files with 667 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ import {
MoveTool,
FreeTransformTool,
ShapeTool,
TextTool,
type BaseTool,
} from '@/tools';
import type { PointerState } from '@/types';
@@ -40,6 +41,7 @@ const tools: Record<string, BaseTool> = {
move: new MoveTool(),
transform: new FreeTransformTool(),
shape: new ShapeTool(),
text: new TextTool(),
};
export function CanvasWithTools() {

View File

@@ -9,6 +9,7 @@ import { ToolOptions } from './tool-options';
import { PanelDock } from './panel-dock';
import { ThemeToggle } from './theme-toggle';
import { ToolPalette } from '@/components/tools';
import { TextDialog } from '@/components/modals/text-dialog';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useFileOperations } from '@/hooks/use-file-operations';
import { useDragDrop } from '@/hooks/use-drag-drop';
@@ -190,6 +191,9 @@ export function EditorLayout() {
{/* Right: Panel Dock */}
<PanelDock />
</div>
{/* Text Dialog */}
<TextDialog />
</div>
);
}

View File

@@ -0,0 +1,288 @@
'use client';
import { useState, useEffect } from 'react';
import { X, Type } from 'lucide-react';
import { useTextStore } from '@/store/text-store';
import { TextTool } from '@/tools/text-tool';
import { WEB_SAFE_FONTS, GOOGLE_FONTS, loadGoogleFont } from '@/lib/text-utils';
import type { FontStyle, FontWeight, TextAlign } from '@/types/text';
export function TextDialog() {
const {
settings,
isDialogOpen,
clickPosition,
setText,
setFontFamily,
setFontSize,
setFontStyle,
setFontWeight,
setColor,
setAlign,
setLineHeight,
setLetterSpacing,
closeDialog,
} = useTextStore();
const [text, setTextLocal] = useState(settings.text);
const [isLoadingFont, setIsLoadingFont] = useState(false);
useEffect(() => {
if (isDialogOpen) {
setTextLocal(settings.text);
}
}, [isDialogOpen, settings.text]);
if (!isDialogOpen || !clickPosition) return null;
const handleInsert = async () => {
if (!text.trim()) {
closeDialog();
return;
}
// Update text in store
setText(text);
// Load Google Font if needed
if (GOOGLE_FONTS.includes(settings.fontFamily as any)) {
try {
setIsLoadingFont(true);
await loadGoogleFont(settings.fontFamily);
} catch (error) {
console.error('Failed to load font:', error);
} finally {
setIsLoadingFont(false);
}
}
// Render text on canvas
TextTool.renderTextOnCanvas(clickPosition.x, clickPosition.y);
// Close dialog
closeDialog();
setTextLocal('');
};
const handleCancel = () => {
closeDialog();
setTextLocal('');
};
const allFonts = [...WEB_SAFE_FONTS, ...GOOGLE_FONTS];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={handleCancel}
>
<div
className="bg-card border border-border rounded-lg shadow-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border sticky top-0 bg-card z-10">
<div className="flex items-center gap-2">
<Type className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold text-card-foreground">Add Text</h2>
</div>
<button
onClick={handleCancel}
className="p-1 hover:bg-accent rounded transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Text Input */}
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">Text</label>
<textarea
value={text}
onChange={(e) => setTextLocal(e.target.value)}
placeholder="Enter your text here..."
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground min-h-[120px] resize-y"
autoFocus
/>
</div>
{/* Font Family */}
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">Font Family</label>
<select
value={settings.fontFamily}
onChange={(e) => setFontFamily(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground"
>
<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}>
{font}
</option>
))}
</optgroup>
</select>
</div>
{/* Font Size, Style, Weight */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">Size</label>
<input
type="number"
min="8"
max="500"
value={settings.fontSize}
onChange={(e) => setFontSize(Number(e.target.value))}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">Style</label>
<select
value={settings.fontStyle}
onChange={(e) => setFontStyle(e.target.value as FontStyle)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground"
>
<option value="normal">Normal</option>
<option value="italic">Italic</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">Weight</label>
<select
value={settings.fontWeight}
onChange={(e) => setFontWeight(e.target.value as FontWeight)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground"
>
<option value="normal">Normal</option>
<option value="bold">Bold</option>
<option value="100">100 - Thin</option>
<option value="300">300 - Light</option>
<option value="500">500 - Medium</option>
<option value="600">600 - Semi Bold</option>
<option value="700">700 - Bold</option>
<option value="900">900 - Black</option>
</select>
</div>
</div>
{/* Color and Alignment */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">Color</label>
<div className="flex gap-2">
<input
type="color"
value={settings.color}
onChange={(e) => setColor(e.target.value)}
className="h-10 w-16 rounded border border-border cursor-pointer"
/>
<input
type="text"
value={settings.color}
onChange={(e) => setColor(e.target.value)}
className="flex-1 px-3 py-2 rounded-md border border-border bg-background text-foreground"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">Alignment</label>
<select
value={settings.align}
onChange={(e) => setAlign(e.target.value as TextAlign)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-foreground"
>
<option value="left">Left</option>
<option value="center">Center</option>
<option value="right">Right</option>
</select>
</div>
</div>
{/* Line Height and Letter Spacing */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">
Line Height ({settings.lineHeight.toFixed(2)})
</label>
<input
type="range"
min="0.5"
max="3"
step="0.1"
value={settings.lineHeight}
onChange={(e) => setLineHeight(Number(e.target.value))}
className="w-full"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">
Letter Spacing ({settings.letterSpacing}px)
</label>
<input
type="range"
min="-10"
max="50"
step="1"
value={settings.letterSpacing}
onChange={(e) => setLetterSpacing(Number(e.target.value))}
className="w-full"
/>
</div>
</div>
{/* Preview */}
<div className="space-y-2">
<label className="text-sm font-medium text-card-foreground">Preview</label>
<div
className="w-full px-4 py-8 rounded-md border border-border bg-background flex items-center justify-center min-h-[100px]"
style={{
color: settings.color,
fontFamily: settings.fontFamily,
fontSize: `${Math.min(settings.fontSize, 48)}px`,
fontStyle: settings.fontStyle,
fontWeight: settings.fontWeight,
textAlign: settings.align,
lineHeight: settings.lineHeight,
letterSpacing: `${settings.letterSpacing}px`,
}}
>
{text || 'Your text will appear here'}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-border sticky bottom-0 bg-card">
<button
onClick={handleCancel}
className="px-4 py-2 rounded-md border border-border hover:bg-accent transition-colors"
>
Cancel
</button>
<button
onClick={handleInsert}
disabled={!text.trim() || isLoadingFont}
className="px-4 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoadingFont ? 'Loading Font...' : 'Insert Text'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
PaintBucket,
MousePointer,
Pipette,
Type,
} from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -18,6 +19,7 @@ const tools: { type: ToolType; icon: React.ReactNode; label: string }[] = [
{ type: 'eraser', icon: <Eraser className="h-5 w-5" />, label: 'Eraser' },
{ type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill' },
{ type: 'eyedropper', icon: <Pipette className="h-5 w-5" />, label: 'Eyedropper' },
{ type: 'text', icon: <Type className="h-5 w-5" />, label: 'Text' },
{ type: 'select', icon: <MousePointer className="h-5 w-5" />, label: 'Select' },
];