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,
|
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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
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
156
lib/text-utils.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
97
store/text-store.ts
Normal 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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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
75
tools/text-tool.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
40
types/text.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user