Files
paint-ui/components/modals/text-dialog.tsx
Sebastian Krüger fea87d3a1e 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>
2025-11-21 09:45:05 +01:00

289 lines
10 KiB
TypeScript

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