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

View File

@@ -9,6 +9,7 @@ import { ToolOptions } from './tool-options';
import { PanelDock } from './panel-dock'; import { PanelDock } from './panel-dock';
import { ThemeToggle } from './theme-toggle'; import { ThemeToggle } from './theme-toggle';
import { ToolPalette } from '@/components/tools'; import { ToolPalette } from '@/components/tools';
import { TextDialog } from '@/components/modals/text-dialog';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts'; import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useFileOperations } from '@/hooks/use-file-operations'; import { useFileOperations } from '@/hooks/use-file-operations';
import { useDragDrop } from '@/hooks/use-drag-drop'; import { useDragDrop } from '@/hooks/use-drag-drop';
@@ -190,6 +191,9 @@ export function EditorLayout() {
{/* Right: Panel Dock */} {/* Right: Panel Dock */}
<PanelDock /> <PanelDock />
</div> </div>
{/* Text Dialog */}
<TextDialog />
</div> </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, PaintBucket,
MousePointer, MousePointer,
Pipette, Pipette,
Type,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; 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: 'eraser', icon: <Eraser className="h-5 w-5" />, label: 'Eraser' },
{ type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill' }, { type: 'fill', icon: <PaintBucket className="h-5 w-5" />, label: 'Fill' },
{ type: 'eyedropper', icon: <Pipette className="h-5 w-5" />, label: 'Eyedropper' }, { 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' }, { type: 'select', icon: <MousePointer className="h-5 w-5" />, label: 'Select' },
]; ];

156
lib/text-utils.ts Normal file
View File

@@ -0,0 +1,156 @@
import type { TextSettings } from '@/types/text';
/**
* Render text on canvas with specified settings
*/
export function renderText(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
settings: TextSettings
): void {
if (!settings.text) return;
ctx.save();
// Build font string
const font = `${settings.fontStyle} ${settings.fontWeight} ${settings.fontSize}px ${settings.fontFamily}`;
ctx.font = font;
ctx.fillStyle = settings.color;
ctx.textAlign = settings.align;
ctx.textBaseline = settings.baseline;
// Handle multi-line text
const lines = settings.text.split('\n');
const lineHeightPx = settings.fontSize * settings.lineHeight;
lines.forEach((line, index) => {
const lineY = y + index * lineHeightPx;
// Apply letter spacing if needed
if (settings.letterSpacing !== 0) {
renderTextWithLetterSpacing(ctx, line, x, lineY, settings.letterSpacing);
} else {
ctx.fillText(line, x, lineY);
}
});
ctx.restore();
}
/**
* Render text with custom letter spacing
*/
function renderTextWithLetterSpacing(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
letterSpacing: number
): void {
const chars = text.split('');
let currentX = x;
// Adjust starting position based on text align
if (ctx.textAlign === 'center') {
const totalWidth = measureTextWidth(ctx, text, letterSpacing);
currentX = x - totalWidth / 2;
} else if (ctx.textAlign === 'right') {
const totalWidth = measureTextWidth(ctx, text, letterSpacing);
currentX = x - totalWidth;
}
// Draw each character with spacing
chars.forEach((char) => {
ctx.fillText(char, currentX, y);
currentX += ctx.measureText(char).width + letterSpacing;
});
}
/**
* Measure text width including letter spacing
*/
function measureTextWidth(
ctx: CanvasRenderingContext2D,
text: string,
letterSpacing: number
): number {
const chars = text.split('');
let width = 0;
chars.forEach((char) => {
width += ctx.measureText(char).width + letterSpacing;
});
return width - letterSpacing; // Remove last letter spacing
}
/**
* Common web-safe fonts
*/
export const WEB_SAFE_FONTS = [
'Arial',
'Arial Black',
'Comic Sans MS',
'Courier New',
'Georgia',
'Impact',
'Lucida Console',
'Lucida Sans Unicode',
'Palatino Linotype',
'Tahoma',
'Times New Roman',
'Trebuchet MS',
'Verdana',
] as const;
/**
* Popular Google Fonts (will be loaded dynamically)
*/
export const GOOGLE_FONTS = [
'Roboto',
'Open Sans',
'Lato',
'Montserrat',
'Oswald',
'Source Sans Pro',
'Raleway',
'PT Sans',
'Merriweather',
'Playfair Display',
'Ubuntu',
'Noto Sans',
'Poppins',
'Inter',
] as const;
/**
* Load a Google Font dynamically
*/
export function loadGoogleFont(fontFamily: string): Promise<void> {
return new Promise((resolve, reject) => {
// Check if font is already loaded
if (document.fonts.check(`16px "${fontFamily}"`)) {
resolve();
return;
}
// Create link element to load font
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${fontFamily.replace(/ /g, '+')}:wght@100;300;400;500;700;900&display=swap`;
link.onload = () => {
// Wait for font to be ready
document.fonts.load(`16px "${fontFamily}"`).then(() => {
resolve();
}).catch(reject);
};
link.onerror = () => {
reject(new Error(`Failed to load font: ${fontFamily}`));
};
document.head.appendChild(link);
});
}

View File

@@ -7,4 +7,5 @@ export * from './color-store';
export * from './selection-store'; export * from './selection-store';
export * from './transform-store'; export * from './transform-store';
export * from './shape-store'; export * from './shape-store';
export * from './text-store';
export * from './ui-store'; export * from './ui-store';

97
store/text-store.ts Normal file
View File

@@ -0,0 +1,97 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { TextSettings, TextStore as ITextStore } from '@/types/text';
const DEFAULT_SETTINGS: TextSettings = {
text: '',
fontFamily: 'Arial',
fontSize: 48,
fontStyle: 'normal',
fontWeight: 'normal',
color: '#000000',
align: 'left',
baseline: 'alphabetic',
lineHeight: 1.2,
letterSpacing: 0,
};
export const useTextStore = create<ITextStore>()(
persist(
(set) => ({
settings: { ...DEFAULT_SETTINGS },
isDialogOpen: false,
clickPosition: null,
setText: (text) =>
set((state) => ({
settings: { ...state.settings, text },
})),
setFontFamily: (fontFamily) =>
set((state) => ({
settings: { ...state.settings, fontFamily },
})),
setFontSize: (fontSize) =>
set((state) => ({
settings: { ...state.settings, fontSize: Math.max(8, Math.min(500, fontSize)) },
})),
setFontStyle: (fontStyle) =>
set((state) => ({
settings: { ...state.settings, fontStyle },
})),
setFontWeight: (fontWeight) =>
set((state) => ({
settings: { ...state.settings, fontWeight },
})),
setColor: (color) =>
set((state) => ({
settings: { ...state.settings, color },
})),
setAlign: (align) =>
set((state) => ({
settings: { ...state.settings, align },
})),
setBaseline: (baseline) =>
set((state) => ({
settings: { ...state.settings, baseline },
})),
setLineHeight: (lineHeight) =>
set((state) => ({
settings: { ...state.settings, lineHeight: Math.max(0.5, Math.min(3, lineHeight)) },
})),
setLetterSpacing: (letterSpacing) =>
set((state) => ({
settings: { ...state.settings, letterSpacing: Math.max(-10, Math.min(50, letterSpacing)) },
})),
updateSettings: (settings) =>
set((state) => ({
settings: { ...state.settings, ...settings },
})),
openDialog: (x, y) =>
set({
isDialogOpen: true,
clickPosition: { x, y },
}),
closeDialog: () =>
set({
isDialogOpen: false,
clickPosition: null,
}),
}),
{
name: 'text-storage',
partialize: (state) => ({ settings: state.settings }), // Only persist settings, not dialog state
}
)
);

View File

@@ -11,3 +11,4 @@ export * from './magic-wand-tool';
export * from './move-tool'; export * from './move-tool';
export * from './free-transform-tool'; export * from './free-transform-tool';
export * from './shape-tool'; export * from './shape-tool';
export * from './text-tool';

75
tools/text-tool.ts Normal file
View File

@@ -0,0 +1,75 @@
import { BaseTool } from './base-tool';
import type { PointerState } from '@/types';
import { useTextStore } from '@/store/text-store';
import { useLayerStore } from '@/store/layer-store';
import { useHistoryStore } from '@/store/history-store';
import { DrawCommand } from '@/core/commands/draw-command';
import { renderText } from '@/lib/text-utils';
/**
* Text tool - Click to place text on canvas
*/
export class TextTool extends BaseTool {
constructor() {
super('Text');
}
onPointerDown(pointer: PointerState): void {
const layer = this.getActiveLayer();
if (!layer) return;
// Open text dialog at click position
const { openDialog } = useTextStore.getState();
openDialog(pointer.x, pointer.y);
}
onPointerMove(): void {
// No-op for text tool
}
onPointerUp(): void {
// No-op for text tool
}
getCursor(): string {
return 'text';
}
/**
* Render text on canvas (called from dialog)
*/
static renderTextOnCanvas(x: number, y: number): void {
const layer = TextTool.getActiveLayerStatic();
if (!layer?.canvas) return;
const ctx = layer.canvas.getContext('2d');
if (!ctx) return;
const { settings } = useTextStore.getState();
// Create draw command for history
const drawCommand = new DrawCommand(layer.id, 'Add Text');
// Render text
renderText(ctx, x, y, settings);
// Add to history
drawCommand.captureAfterState();
const { executeCommand } = useHistoryStore.getState();
executeCommand(drawCommand);
// Update layer to trigger re-render
const { updateLayer } = useLayerStore.getState();
updateLayer(layer.id, { updatedAt: Date.now() });
}
private getActiveLayer() {
const { activeLayerId, layers } = useLayerStore.getState();
return layers.find((l) => l.id === activeLayerId);
}
private static getActiveLayerStatic() {
const { activeLayerId, layers } = useLayerStore.getState();
return layers.find((l) => l.id === activeLayerId);
}
}

View File

@@ -6,3 +6,4 @@ export * from './filter';
export * from './selection'; export * from './selection';
export * from './transform'; export * from './transform';
export * from './shape'; export * from './shape';
export * from './text';

40
types/text.ts Normal file
View File

@@ -0,0 +1,40 @@
export type TextAlign = 'left' | 'center' | 'right';
export type TextBaseline = 'top' | 'middle' | 'bottom' | 'alphabetic';
export type FontStyle = 'normal' | 'italic';
export type FontWeight = 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
export interface TextSettings {
text: string;
fontFamily: string;
fontSize: number;
fontStyle: FontStyle;
fontWeight: FontWeight;
color: string;
align: TextAlign;
baseline: TextBaseline;
lineHeight: number;
letterSpacing: number;
}
export interface TextStore {
settings: TextSettings;
isDialogOpen: boolean;
clickPosition: { x: number; y: number } | null;
// Setters
setText: (text: string) => void;
setFontFamily: (fontFamily: string) => void;
setFontSize: (fontSize: number) => void;
setFontStyle: (fontStyle: FontStyle) => void;
setFontWeight: (fontWeight: FontWeight) => void;
setColor: (color: string) => void;
setAlign: (align: TextAlign) => void;
setBaseline: (baseline: TextBaseline) => void;
setLineHeight: (lineHeight: number) => void;
setLetterSpacing: (letterSpacing: number) => void;
updateSettings: (settings: Partial<TextSettings>) => void;
// Dialog control
openDialog: (x: number, y: number) => void;
closeDialog: () => void;
}