Files
paint-ui/components/modals/text-dialog.tsx

289 lines
10 KiB
TypeScript
Raw Normal View History

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