Files
paint-ui/lib/text-utils.ts
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

157 lines
3.5 KiB
TypeScript

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