diff --git a/app/globals.css b/app/globals.css index 3cd1ab1..b8207ad 100644 --- a/app/globals.css +++ b/app/globals.css @@ -330,3 +330,16 @@ background-position: 0 0, 0 10px, 10px -10px, -10px 0px; } } + +/* Text editor - make text visible when selected */ +@layer components { + .text-editor-input::selection { + background-color: var(--primary); + color: var(--primary-foreground); + } + + .text-editor-input::-moz-selection { + background-color: var(--primary); + color: var(--primary-foreground); + } +} diff --git a/components/canvas/canvas-with-tools.tsx b/components/canvas/canvas-with-tools.tsx index f1aff0b..6562e6c 100644 --- a/components/canvas/canvas-with-tools.tsx +++ b/components/canvas/canvas-with-tools.tsx @@ -4,8 +4,10 @@ import { useEffect, useRef, useState } from 'react'; import { useCanvasStore, useLayerStore, useToolStore } from '@/store'; import { useHistoryStore } from '@/store/history-store'; import { useSelectionStore } from '@/store/selection-store'; +import { useTextStore } from '@/store/text-store'; import { drawMarchingAnts } from '@/lib/selection-utils'; import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils'; +import { renderText } from '@/lib/text-utils'; import { DrawCommand } from '@/core/commands'; import { PencilTool, @@ -25,6 +27,7 @@ import { } from '@/tools'; import type { PointerState } from '@/types'; import { cn } from '@/lib/utils'; +import { OnCanvasTextEditor } from './on-canvas-text-editor'; // Tool instances const tools: Record = { @@ -66,6 +69,7 @@ export function CanvasWithTools() { const { activeTool, settings } = useToolStore(); const { executeCommand } = useHistoryStore(); const { activeSelection, selectionType, isMarching } = useSelectionStore(); + const { textObjects, editingTextId } = useTextStore(); const [marchingOffset, setMarchingOffset] = useState(0); const [isPanning, setIsPanning] = useState(false); @@ -129,6 +133,27 @@ export function CanvasWithTools() { ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; + // Draw text objects (skip the one being edited) + textObjects.forEach((textObj) => { + // Don't render text that's currently being edited + if (editingTextId && textObj.id === editingTextId) { + return; + } + + renderText(ctx, textObj.x, textObj.y, { + text: textObj.text, + fontFamily: textObj.fontFamily, + fontSize: textObj.fontSize, + fontStyle: textObj.fontStyle, + fontWeight: textObj.fontWeight, + color: textObj.color, + align: textObj.align, + baseline: textObj.baseline, + lineHeight: textObj.lineHeight, + letterSpacing: textObj.letterSpacing, + }); + }); + // Draw grid if enabled (only within canvas bounds) if (showGrid) { drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)', width, height); @@ -141,7 +166,7 @@ export function CanvasWithTools() { // Restore context state ctx.restore(); - }, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection, pointer, activeSelection, isMarching, marchingOffset]); + }, [layers, width, height, zoom, offsetX, offsetY, showGrid, gridSize, backgroundColor, selection, pointer, activeSelection, isMarching, marchingOffset, textObjects, editingTextId]); // Marching ants animation useEffect(() => { @@ -353,6 +378,9 @@ export function CanvasWithTools() { ref={canvasRef} className="absolute inset-0" /> + + {/* On-canvas text editor */} + ); } diff --git a/components/canvas/on-canvas-text-editor.tsx b/components/canvas/on-canvas-text-editor.tsx new file mode 100644 index 0000000..b1e8602 --- /dev/null +++ b/components/canvas/on-canvas-text-editor.tsx @@ -0,0 +1,411 @@ +'use client'; + +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useTextStore } from '@/store/text-store'; +import { useCanvasStore } from '@/store/canvas-store'; +import { useLayerStore } from '@/store/layer-store'; +import { useHistoryStore } from '@/store/history-store'; +import { TextTool } from '@/tools/text-tool'; +import { renderText } from '@/lib/text-utils'; +import { DrawCommand } from '@/core/commands/draw-command'; + +export function OnCanvasTextEditor() { + const { + isOnCanvasEditorActive, + editorPosition, + editorText, + editingTextId, + settings, + updateEditorText, + updateEditorPosition, + deactivateOnCanvasEditor, + addTextObject, + updateTextObject, + setText, + } = useTextStore(); + + const { zoom, offsetX, offsetY, width: canvasWidth, height: canvasHeight } = useCanvasStore(); + const { getActiveLayer } = useLayerStore(); + const { executeCommand } = useHistoryStore(); + + const [screenPosition, setScreenPosition] = useState({ x: 0, y: 0 }); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0, initialCanvasX: 0, initialCanvasY: 0 }); + const [textBounds, setTextBounds] = useState({ width: 0, height: 0 }); + + const textareaRef = useRef(null); + const previewCanvasRef = useRef(null); + const containerRef = useRef(null); + const commitTextRef = useRef<(() => void) | null>(null); + + // Calculate screen position from canvas position + const calculateScreenPosition = useCallback(() => { + if (!editorPosition || !containerRef.current) return { x: 0, y: 0 }; + + const container = containerRef.current.parentElement; + if (!container) return { x: 0, y: 0 }; + + const rect = container.getBoundingClientRect(); + + // Apply canvas transformation: canvas coords -> screen coords + // The canvas uses: translate(offsetX + width/2, offsetY + height/2), scale(zoom), translate(-canvasWidth/2, -canvasHeight/2) + const transformedX = (editorPosition.x - canvasWidth / 2) * zoom + (rect.width / 2) + offsetX; + const transformedY = (editorPosition.y - canvasHeight / 2) * zoom + (rect.height / 2) + offsetY; + + return { + x: transformedX, + y: transformedY, + }; + }, [editorPosition, zoom, offsetX, offsetY, canvasWidth, canvasHeight]); + + // Update screen position when canvas transforms or position changes + useEffect(() => { + if (isOnCanvasEditorActive && editorPosition) { + const pos = calculateScreenPosition(); + setScreenPosition(pos); + } + }, [isOnCanvasEditorActive, editorPosition, calculateScreenPosition]); + + // Measure text bounds + const measureTextBounds = useCallback((text: string) => { + if (!previewCanvasRef.current) { + return { width: 200, height: settings.fontSize * settings.lineHeight }; + } + + const canvas = previewCanvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return { width: 200, height: settings.fontSize * settings.lineHeight }; + } + + // Measure text + ctx.font = `${settings.fontStyle} ${settings.fontWeight} ${settings.fontSize}px ${settings.fontFamily}`; + const lines = text ? text.split('\n') : ['']; + + let maxWidth = 0; + lines.forEach((line) => { + const width = ctx.measureText(line || ' ').width; // Measure space if empty + if (width > maxWidth) maxWidth = width; + }); + + // Add padding for better visibility and ensure minimum size + const width = Math.max(maxWidth + 40, 200); + const height = Math.max(lines.length * settings.fontSize * settings.lineHeight + 10, settings.fontSize * settings.lineHeight + 10); + + return { width, height }; + }, [settings]); + + // Update text bounds when text or settings change + useEffect(() => { + const bounds = measureTextBounds(editorText); + setTextBounds(bounds); + }, [editorText, settings, measureTextBounds]); + + // Render text preview on canvas + useEffect(() => { + if (!previewCanvasRef.current) return; + + const canvas = previewCanvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Set canvas size to match text bounds (scaled) + canvas.width = textBounds.width * zoom; + canvas.height = textBounds.height * zoom; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (!editorText) return; + + // Scale context for rendering + ctx.save(); + ctx.scale(zoom, zoom); + + // Render text at origin with 'top' baseline so it's visible + // Add small padding from top edge + renderText(ctx, 10, 0, { ...settings, text: editorText, baseline: 'top' }); + + ctx.restore(); + }, [editorText, settings, textBounds, zoom]); + + // Handle commit text + const commitText = useCallback(() => { + if (!editorPosition) return; + + if (!editorText.trim()) { + // Empty text, just close editor + deactivateOnCanvasEditor(); + return; + } + + const activeLayer = getActiveLayer(); + if (!activeLayer) { + deactivateOnCanvasEditor(); + return; + } + + if (editingTextId) { + // Update existing text object - update position if moved + updateTextObject(editingTextId, { + x: editorPosition.x + 10, + y: editorPosition.y, + text: editorText, + fontFamily: settings.fontFamily, + fontSize: settings.fontSize, + fontStyle: settings.fontStyle, + fontWeight: settings.fontWeight, + color: settings.color, + align: settings.align, + baseline: 'top', + lineHeight: settings.lineHeight, + letterSpacing: settings.letterSpacing, + }); + } else { + // Create new text object + addTextObject({ + layerId: activeLayer.id, + x: editorPosition.x + 10, + y: editorPosition.y, + text: editorText, + fontFamily: settings.fontFamily, + fontSize: settings.fontSize, + fontStyle: settings.fontStyle, + fontWeight: settings.fontWeight, + color: settings.color, + align: settings.align, + baseline: 'top', + lineHeight: settings.lineHeight, + letterSpacing: settings.letterSpacing, + }); + } + + // Update settings text for future use + setText(editorText); + + // Close editor + deactivateOnCanvasEditor(); + }, [editorPosition, editorText, editingTextId, settings, deactivateOnCanvasEditor, addTextObject, updateTextObject, setText, getActiveLayer]); + + // Store commitText in ref for click outside handler + useEffect(() => { + commitTextRef.current = commitText; + }, [commitText]); + + // Handle keyboard shortcuts + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); + commitText(); + } else if (e.key === 'Escape') { + e.preventDefault(); + deactivateOnCanvasEditor(); + } + }, + [commitText, deactivateOnCanvasEditor] + ); + + // Handle drag start + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + if (!editorPosition) return; + + setIsDragging(true); + setDragStart({ + x: e.clientX, + y: e.clientY, + initialCanvasX: editorPosition.x, + initialCanvasY: editorPosition.y, + }); + e.preventDefault(); + e.stopPropagation(); + }, + [editorPosition] + ); + + // Handle drag move + const handleMouseMove = useCallback( + (e: globalThis.MouseEvent) => { + if (!isDragging) return; + + const deltaX = e.clientX - dragStart.x; + const deltaY = e.clientY - dragStart.y; + + // Convert screen delta to canvas delta + const canvasDeltaX = deltaX / zoom; + const canvasDeltaY = deltaY / zoom; + + updateEditorPosition( + dragStart.initialCanvasX + canvasDeltaX, + dragStart.initialCanvasY + canvasDeltaY + ); + }, + [isDragging, dragStart, zoom, updateEditorPosition] + ); + + // Handle drag end + const handleMouseUp = useCallback(() => { + setIsDragging(false); + }, []); + + // Attach global mouse listeners for dragging + useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + // No separate click-outside handler needed - using overlay approach + + // Focus textarea when editor activates + useEffect(() => { + if (isOnCanvasEditorActive) { + setTimeout(() => { + textareaRef.current?.focus(); + }, 10); + } + }, [isOnCanvasEditorActive]); + + if (!isOnCanvasEditorActive || !editorPosition) return null; + + const scaledWidth = textBounds.width * zoom; + const scaledHeight = textBounds.height * zoom; + + return ( + <> + {/* Full-screen overlay to capture outside clicks */} +
{ + e.preventDefault(); + e.stopPropagation(); + commitText(); + }} + /> + +
+ {/* Bounding box - visual only */} +
+ {/* Transform handles - draggable */} +
+
+
+
+
+
+
+
+
+ + {/* Text preview canvas */} + + + {/* Transparent textarea for input */} +