Files
paint-ui/components/canvas/on-canvas-text-editor.tsx

418 lines
14 KiB
TypeScript
Raw Permalink Normal View History

'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,
deleteTextObject,
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<HTMLTextAreaElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(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();
} else if ((e.key === 'Delete' || e.key === 'Backspace') && editingTextId && !editorText.trim()) {
// Delete the entire text object if it's empty and we're editing an existing text
e.preventDefault();
deleteTextObject(editingTextId);
deactivateOnCanvasEditor();
}
},
[commitText, deactivateOnCanvasEditor, editingTextId, editorText, deleteTextObject]
);
// 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 */}
<div
className="fixed inset-0"
style={{
zIndex: 999,
pointerEvents: 'auto',
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
commitText();
}}
/>
<div
ref={containerRef}
className="absolute"
style={{
left: `${screenPosition.x}px`,
top: `${screenPosition.y}px`,
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
zIndex: 1000,
pointerEvents: 'auto',
}}
>
{/* Bounding box - visual only */}
<div
className="absolute inset-0 border-2 border-primary pointer-events-none"
style={{
borderStyle: 'dashed',
}}
>
{/* Transform handles - draggable */}
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -left-1 -top-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full left-1/2 -translate-x-1/2 -top-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -right-1 -top-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -left-1 top-1/2 -translate-y-1/2 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -right-1 top-1/2 -translate-y-1/2 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -left-1 -bottom-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full left-1/2 -translate-x-1/2 -bottom-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
<div
className="absolute w-2 h-2 bg-white border-2 border-primary rounded-full -right-1 -bottom-1 cursor-move"
style={{ pointerEvents: 'auto' }}
onMouseDown={handleMouseDown}
/>
</div>
{/* Text preview canvas */}
<canvas
ref={previewCanvasRef}
className="absolute left-0 top-0 pointer-events-none"
style={{
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
zIndex: 1,
}}
/>
{/* Transparent textarea for input */}
<textarea
ref={textareaRef}
value={editorText}
onChange={(e) => updateEditorText(e.target.value)}
onKeyDown={handleKeyDown}
onMouseDown={(e) => {
// Stop propagation to prevent overlay from committing during text interaction
e.stopPropagation();
}}
wrap="off"
spellCheck={false}
autoComplete="off"
className="absolute left-0 top-0 resize-none overflow-hidden outline-none border-none text-editor-input"
style={{
width: `${scaledWidth}px`,
height: `${scaledHeight}px`,
background: 'transparent',
color: 'transparent',
caretColor: settings.color,
fontFamily: settings.fontFamily,
fontSize: `${settings.fontSize * zoom}px`,
fontStyle: settings.fontStyle,
fontWeight: settings.fontWeight,
lineHeight: settings.lineHeight,
letterSpacing: `${settings.letterSpacing * zoom}px`,
textAlign: settings.align,
padding: 0,
paddingLeft: `${10 * zoom}px`,
margin: 0,
whiteSpace: 'pre',
zIndex: 10,
userSelect: 'text',
WebkitUserSelect: 'text',
MozUserSelect: 'text',
cursor: 'text',
}}
/>
</div>
</>
);
}