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>
157 lines
3.5 KiB
TypeScript
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);
|
|
});
|
|
}
|