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:
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
288
components/modals/text-dialog.tsx
Normal file
288
components/modals/text-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user